@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.5

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 (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/config/settings-schema.d.ts +3 -22
  3. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  4. package/dist/types/extensibility/shared-events.d.ts +2 -2
  5. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  6. package/dist/types/internal-urls/types.d.ts +14 -0
  7. package/dist/types/lsp/client.d.ts +3 -0
  8. package/dist/types/mcp/manager.d.ts +14 -5
  9. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  10. package/dist/types/session/agent-session.d.ts +2 -6
  11. package/dist/types/session/shake-types.d.ts +3 -3
  12. package/dist/types/task/repair-args.d.ts +52 -0
  13. package/dist/types/tiny/models.d.ts +0 -14
  14. package/dist/types/tiny/title-client.d.ts +28 -2
  15. package/dist/types/tiny/title-protocol.d.ts +8 -9
  16. package/dist/types/tools/find.d.ts +1 -1
  17. package/dist/types/tools/path-utils.d.ts +7 -0
  18. package/dist/types/tui/output-block.d.ts +7 -7
  19. package/package.json +9 -9
  20. package/scripts/build-binary.ts +0 -1
  21. package/src/cli.ts +59 -0
  22. package/src/config/settings-schema.ts +3 -24
  23. package/src/config/settings.ts +10 -0
  24. package/src/extensibility/custom-tools/types.ts +2 -2
  25. package/src/extensibility/shared-events.ts +2 -2
  26. package/src/internal-urls/docs-index.generated.ts +2 -2
  27. package/src/internal-urls/local-protocol.ts +23 -11
  28. package/src/internal-urls/types.ts +15 -0
  29. package/src/lsp/client.ts +28 -5
  30. package/src/mcp/manager.ts +87 -4
  31. package/src/modes/controllers/command-controller.ts +7 -39
  32. package/src/modes/controllers/event-controller.ts +33 -26
  33. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  34. package/src/prompts/system/project-prompt.md +3 -2
  35. package/src/prompts/system/subagent-system-prompt.md +12 -8
  36. package/src/prompts/system/system-prompt.md +8 -6
  37. package/src/session/agent-session.ts +11 -93
  38. package/src/session/shake-types.ts +4 -5
  39. package/src/slash-commands/builtin-registry.ts +2 -4
  40. package/src/task/executor.ts +14 -4
  41. package/src/task/index.ts +3 -2
  42. package/src/task/repair-args.ts +117 -0
  43. package/src/tiny/models.ts +0 -28
  44. package/src/tiny/title-client.ts +133 -43
  45. package/src/tiny/title-protocol.ts +11 -16
  46. package/src/tiny/worker.ts +6 -61
  47. package/src/tools/ast-edit.ts +3 -0
  48. package/src/tools/ast-grep.ts +3 -0
  49. package/src/tools/find.ts +20 -6
  50. package/src/tools/gh.ts +1 -0
  51. package/src/tools/path-utils.ts +13 -2
  52. package/src/tools/read.ts +1 -0
  53. package/src/tools/search.ts +12 -1
  54. package/src/tui/output-block.ts +37 -75
  55. package/src/utils/git.ts +9 -3
@@ -5,7 +5,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
5
5
  import { AgentRegistry } from "../registry/agent-registry";
6
6
  import { parseInternalUrl } from "./parse";
7
7
  import { validateRelativePath } from "./skill-protocol";
8
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
8
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
9
9
 
10
10
  export interface LocalProtocolOptions {
11
11
  getArtifactsDir?: () => string | null;
@@ -164,13 +164,25 @@ export class LocalProtocolHandler implements ProtocolHandler {
164
164
  * Returns the active local-protocol options.
165
165
  *
166
166
  * Resolution order:
167
- * 1. Explicit override installed via {@link setOverride} (used by subagents
168
- * that share their parent's root and by SDK consumers with a custom
169
- * artifacts/session id mapping).
170
- * 2. The main session in `AgentRegistry.global()`. Its `SessionManager`
171
- * supplies both `getArtifactsDir` and `getSessionId`.
167
+ * 1. **Caller-supplied** `context.localProtocolOptions` (the actual session
168
+ * that initiated the `read`/`find`/`search`/`router.resolve` call). This
169
+ * is what keeps `local://` reads pinned to the calling session in
170
+ * multi-session hosts (cmux/ACP, embedded SDK consumers) where every
171
+ * session registers as `kind: "main"` and "first one wins" would route
172
+ * to the wrong artifacts directory.
173
+ * 2. Explicit process-global override installed via {@link setOverride}
174
+ * (used by SDK consumers with a custom artifacts/session-id mapping and
175
+ * by code paths that do not have a calling session, e.g. TUI hyperlink
176
+ * resolution).
177
+ * 3. The first `main`-kind session in `AgentRegistry.global()`. Its
178
+ * `SessionManager` supplies both `getArtifactsDir` and `getSessionId`.
179
+ * Last-resort fallback — every caller that has a session reference
180
+ * SHOULD thread it through `context` so this branch is never taken in
181
+ * multi-session setups.
172
182
  */
173
- static resolveOptions(): LocalProtocolOptions | undefined {
183
+ static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined {
184
+ const fromContext = context?.localProtocolOptions;
185
+ if (fromContext) return fromContext;
174
186
  const override = LocalProtocolHandler.#override;
175
187
  if (override) return override;
176
188
  const main = AgentRegistry.global()
@@ -184,8 +196,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
184
196
  };
185
197
  }
186
198
 
187
- async resolve(url: InternalUrl): Promise<InternalResource> {
188
- const opts = LocalProtocolHandler.resolveOptions();
199
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
200
+ const opts = LocalProtocolHandler.resolveOptions(context);
189
201
  if (!opts) {
190
202
  throw new Error("No session - local:// unavailable");
191
203
  }
@@ -247,8 +259,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
247
259
  };
248
260
  }
249
261
 
250
- async complete(): Promise<UrlCompletion[]> {
251
- const opts = LocalProtocolHandler.resolveOptions();
262
+ async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
263
+ const opts = LocalProtocolHandler.resolveOptions(context);
252
264
  if (!opts) return [];
253
265
  const localRoot = path.resolve(resolveLocalRoot(opts));
254
266
  try {
@@ -5,6 +5,8 @@
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
8
+ import type { LocalProtocolOptions } from "./local-protocol";
9
+
8
10
  /**
9
11
  * Raw resource payload returned by protocol handlers. The `immutable` flag is
10
12
  * applied by the router from {@link ProtocolHandler.immutable}, so handlers do
@@ -77,6 +79,17 @@ export interface ResolveContext {
77
79
  settings?: unknown;
78
80
  /** Caller's abort signal. */
79
81
  signal?: AbortSignal;
82
+ /**
83
+ * Calling session's `local://` root mapping. When present, the local-protocol
84
+ * handler resolves the URL against THIS session's artifacts dir instead of
85
+ * picking the first `main`-kind session from the global `AgentRegistry`.
86
+ *
87
+ * Required for correctness in multi-session hosts (cmux/ACP, embedded SDK
88
+ * consumers) where multiple sessions are registered as `main` and the
89
+ * "first one wins" lookup picks the wrong artifacts directory — see
90
+ * [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
91
+ */
92
+ localProtocolOptions?: LocalProtocolOptions;
80
93
  }
81
94
 
82
95
  /**
@@ -89,6 +102,8 @@ export interface WriteContext {
89
102
  cwd?: string;
90
103
  /** Caller's abort signal. */
91
104
  signal?: AbortSignal;
105
+ /** Calling session's `local://` root mapping — see {@link ResolveContext.localProtocolOptions}. */
106
+ localProtocolOptions?: LocalProtocolOptions;
92
107
  }
93
108
 
94
109
  /**
package/src/lsp/client.ts CHANGED
@@ -415,6 +415,10 @@ export const WARMUP_TIMEOUT_MS = 5000;
415
415
  /** Max time to wait for the server to report project loading completion via $/progress */
416
416
  const PROJECT_LOAD_TIMEOUT_MS = 15_000;
417
417
 
418
+ /** Max time to wait for graceful LSP shutdown and process exit. */
419
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
420
+ const EXIT_TIMEOUT_MS = 1_000;
421
+
418
422
  /**
419
423
  * Get or create an LSP client for the given server configuration and working directory.
420
424
  * @param config - Server configuration
@@ -768,8 +772,18 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
768
772
  }
769
773
  }
770
774
 
775
+ async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolean> {
776
+ return await Promise.race([
777
+ client.proc.exited.then(
778
+ () => true,
779
+ () => true,
780
+ ),
781
+ Bun.sleep(timeoutMs).then(() => false),
782
+ ]);
783
+ }
784
+
771
785
  /**
772
- * Shutdown a specific client by key.
786
+ * Shutdown a specific client instance using the LSP shutdown/exit handshake.
773
787
  */
774
788
  async function shutdownClientInstance(client: LspClient): Promise<void> {
775
789
  const err = new Error("LSP client shutdown");
@@ -778,13 +792,22 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
778
792
  }
779
793
  client.pendingRequests.clear();
780
794
 
781
- const timeout = Bun.sleep(5_000);
782
- const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
783
- await Promise.race([shutdown, timeout]);
795
+ const shutdownCompleted = await sendRequest(client, "shutdown", null, undefined, SHUTDOWN_TIMEOUT_MS).then(
796
+ () => true,
797
+ () => false,
798
+ );
799
+ if (shutdownCompleted) {
800
+ await sendNotification(client, "exit", undefined).catch(() => {});
801
+ if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
802
+ }
803
+
784
804
  client.proc.kill();
785
- await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
805
+ await waitForExit(client, EXIT_TIMEOUT_MS);
786
806
  }
787
807
 
808
+ /**
809
+ * Shutdown a specific client by key.
810
+ */
788
811
  export async function shutdownClient(key: string): Promise<void> {
789
812
  const client = clients.get(key);
790
813
  if (!client) return;
@@ -59,6 +59,27 @@ type TrackedPromise<T> = {
59
59
 
60
60
  const STARTUP_TIMEOUT_MS = 250;
61
61
 
62
+ /**
63
+ * Per-server reconnect-storm circuit breaker.
64
+ *
65
+ * `transport.onClose` (wired in {@link MCPManager.connectServers} and
66
+ * {@link MCPManager.#connectAndWireServer}) fires `reconnectServer` on every
67
+ * clean process exit, so a stdio MCP server that completes the
68
+ * `initialize` + `tools/list` handshake and then exits will pull the agent
69
+ * into a fork loop with no rate limit. That pathology shipped in issue #1592
70
+ * (a `php`-shebang MCP fork-bombing macOS, parented directly to the agent's
71
+ * `bun` PID via shebang exec).
72
+ *
73
+ * We keep the sliding window short — older crashes age out so a single
74
+ * transient failure stays cheap — but cap the burst tightly enough that the
75
+ * agent never spawns more than `RECONNECT_BURST_LIMIT * #doReconnect retries`
76
+ * (≤ 25) processes per stuck server per window. Manual `/mcp reconnect`
77
+ * resets the window so users can recover after fixing the underlying
78
+ * misconfiguration.
79
+ */
80
+ const RECONNECT_BURST_WINDOW_MS = 30_000;
81
+ const RECONNECT_BURST_LIMIT = 5;
82
+
62
83
  function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
63
84
  const tracked: TrackedPromise<T> = { promise, status: "pending" };
64
85
  promise.then(
@@ -166,6 +187,11 @@ export class MCPManager {
166
187
  #pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
167
188
  /** Preserved configs for reconnection after connection loss. */
168
189
  #serverConfigs = new Map<string, MCPServerConfig>();
190
+ /**
191
+ * Timestamps of recent `reconnectServer` invocations per server, used by the
192
+ * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
193
+ */
194
+ #reconnectHistory = new Map<string, number[]>();
169
195
  /** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
170
196
  #epoch = 0;
171
197
 
@@ -666,6 +692,7 @@ export class MCPManager {
666
692
  this.#sources.delete(name);
667
693
  this.#serverConfigs.delete(name);
668
694
  this.#pendingResourceRefresh.delete(name);
695
+ this.#reconnectHistory.delete(name);
669
696
 
670
697
  const connection = this.#connections.get(name);
671
698
 
@@ -714,24 +741,80 @@ export class MCPManager {
714
741
  this.#connections.clear();
715
742
  this.#tools = [];
716
743
  this.#subscribedResources.clear();
744
+ this.#reconnectHistory.clear();
717
745
  }
718
746
 
719
747
  /**
720
748
  * Reconnect to a server after a connection failure.
749
+ *
721
750
  * Tears down the stale connection, re-resolves auth, establishes a new
722
- * connection, reloads tools, and notifies consumers.
723
- * Concurrent calls for the same server share one reconnection attempt.
724
- * Returns the new connection, or null if reconnection failed.
751
+ * connection, reloads tools, and notifies consumers. Concurrent calls for
752
+ * the same server share one reconnection attempt. Returns the new
753
+ * connection, or `null` if reconnection failed or the per-server crash
754
+ * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
755
+ *
756
+ * @param options.manual - When `true`, resets the crash-burst window so a
757
+ * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
758
+ * earlier storm. Defaults to `false`; the transport `onClose` callback
759
+ * and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
725
760
  */
726
- async reconnectServer(name: string): Promise<MCPServerConnection | null> {
761
+ async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
762
+ if (options?.manual) {
763
+ this.#reconnectHistory.delete(name);
764
+ }
765
+
727
766
  const pending = this.#pendingReconnections.get(name);
728
767
  if (pending) return pending;
729
768
 
769
+ if (this.#tripReconnectBreaker(name)) {
770
+ return null;
771
+ }
772
+
730
773
  const attempt = this.#doReconnect(name);
731
774
  this.#pendingReconnections.set(name, attempt);
732
775
  return attempt.finally(() => this.#pendingReconnections.delete(name));
733
776
  }
734
777
 
778
+ /**
779
+ * Record a reconnect attempt against the per-server crash window and report
780
+ * whether the circuit breaker is now open. Sliding window: entries older
781
+ * than {@link RECONNECT_BURST_WINDOW_MS} are pruned before the new
782
+ * timestamp is appended, so a single transient failure ages out cheaply
783
+ * but repeated rapid crashes accumulate until the limit is hit.
784
+ */
785
+ #tripReconnectBreaker(name: string): boolean {
786
+ const now = Date.now();
787
+ const previous = this.#reconnectHistory.get(name) ?? [];
788
+ const recent = previous.filter(ts => now - ts < RECONNECT_BURST_WINDOW_MS);
789
+ recent.push(now);
790
+ this.#reconnectHistory.set(name, recent);
791
+
792
+ if (recent.length > RECONNECT_BURST_LIMIT) {
793
+ logger.error("MCP server crashed too many times; suspending automatic reconnects", {
794
+ path: `mcp:${name}`,
795
+ crashes: recent.length,
796
+ windowMs: RECONNECT_BURST_WINDOW_MS,
797
+ });
798
+ // Tear down the stale connection so `getConnectionStatus()` no
799
+ // longer reports it as "connected" and `waitForConnection()` does
800
+ // not hand a closed transport to callers. Tools stay registered
801
+ // in `#tools` — the user can recover with `/mcp reconnect <name>`
802
+ // once they've fixed the underlying misconfiguration. Mirrors the
803
+ // teardown in `#doReconnect`: detach `onClose` first so the
804
+ // transport's own `close()` cannot re-arm this path.
805
+ const stale = this.#connections.get(name);
806
+ if (stale) {
807
+ stale.transport.onClose = undefined;
808
+ void stale.transport.close().catch(() => {});
809
+ this.#connections.delete(name);
810
+ }
811
+ this.#pendingConnections.delete(name);
812
+ this.#pendingToolLoads.delete(name);
813
+ return true;
814
+ }
815
+ return false;
816
+ }
817
+
735
818
  async #doReconnect(name: string): Promise<MCPServerConnection | null> {
736
819
  const oldConnection = this.#connections.get(name);
737
820
  const config = oldConnection?.config ?? this.#serverConfigs.get(name);
@@ -1124,48 +1124,16 @@ export class CommandController {
1124
1124
  }
1125
1125
 
1126
1126
  /**
1127
- * TUI handler for `/shake`. `elide`/`images` are instant structural drops;
1128
- * `summary` runs the local on-device compressor behind a cancelable loader
1129
- * (Esc aborts via `abortCompaction`). Rebuilds the chat and reports counts.
1127
+ * TUI handler for `/shake`. `elide` drops heavy structural content and
1128
+ * `images` strips image blocks. Rebuilds the chat and reports counts.
1130
1129
  */
1131
1130
  async handleShakeCommand(mode: ShakeMode): Promise<void> {
1132
1131
  let result: ShakeResult;
1133
- if (mode === "summary") {
1134
- if (this.ctx.loadingAnimation) {
1135
- this.ctx.loadingAnimation.stop();
1136
- this.ctx.loadingAnimation = undefined;
1137
- }
1138
- this.ctx.statusContainer.clear();
1139
- const originalOnEscape = this.ctx.editor.onEscape;
1140
- this.ctx.editor.onEscape = () => {
1141
- this.ctx.session.abortCompaction();
1142
- };
1143
- const loader = new Loader(
1144
- this.ctx.ui,
1145
- spinner => theme.fg("accent", spinner),
1146
- text => theme.fg("muted", text),
1147
- "Shaking context (summary)… (esc to cancel)",
1148
- getSymbolTheme().spinnerFrames,
1149
- );
1150
- this.ctx.statusContainer.addChild(loader);
1151
- this.ctx.ui.requestRender();
1152
- try {
1153
- result = await this.ctx.session.shake("summary");
1154
- } catch (error) {
1155
- this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1156
- return;
1157
- } finally {
1158
- loader.stop();
1159
- this.ctx.statusContainer.clear();
1160
- this.ctx.editor.onEscape = originalOnEscape;
1161
- }
1162
- } else {
1163
- try {
1164
- result = await this.ctx.session.shake(mode);
1165
- } catch (error) {
1166
- this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1167
- return;
1168
- }
1132
+ try {
1133
+ result = await this.ctx.session.shake(mode);
1134
+ } catch (error) {
1135
+ this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1136
+ return;
1169
1137
  }
1170
1138
 
1171
1139
  const dropped = result.toolResultsDropped + result.blocksDropped + (result.imagesDropped ?? 0);
@@ -25,12 +25,14 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
25
25
 
26
26
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
27
27
 
28
- // Events that change which foreground tools are executing, or that reset a turn.
29
- // The eager native-scrollback rebuild mode is recomputed only on these other
30
- // events (assistant text streaming, IRC, notices) leave it untouched so plain
31
- // streaming keeps the no-yank deferral.
32
- const TOOL_RENDER_MODE_EVENTS: Record<string, true> = {
28
+ // Events that change foreground streaming state, or that reset a turn. The TUI
29
+ // eager native-scrollback rebuild mode is recomputed only on these so unrelated
30
+ // IRC/notices/status refreshes do not toggle scrollback replay policy.
31
+ const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
33
32
  agent_start: true,
33
+ agent_end: true,
34
+ message_start: true,
35
+ message_end: true,
34
36
  tool_execution_start: true,
35
37
  tool_execution_update: true,
36
38
  tool_execution_end: true,
@@ -46,6 +48,7 @@ export class EventController {
46
48
  #renderedCustomMessages = new Set<string>();
47
49
  #lastIntent: string | undefined = undefined;
48
50
  #backgroundToolCallIds = new Set<string>();
51
+ #assistantMessageStreaming = false;
49
52
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
50
53
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
51
54
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -169,24 +172,27 @@ export class EventController {
169
172
 
170
173
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
171
174
  await run(event);
172
- // While a foreground tool is executing, its streaming result re-renders and can
173
- // re-lay-out rows that already scrolled into native scrollback. Let the TUI
174
- // rebuild history on those offscreen edits (a snap to the tail is acceptable
175
- // mid-tool) instead of deferring, which would leave stale/duplicated rows.
176
- // Background-running tools are excluded so their late async updates — and the
177
- // assistant text that streams alongside them keep the no-yank deferral;
178
- // agent_start resets the mode at every turn boundary.
179
- if (TOOL_RENDER_MODE_EVENTS[event.type]) {
175
+ // While assistant text or a foreground tool is streaming, rows above the
176
+ // viewport can re-layout after they have already entered native scrollback
177
+ // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
+ // those offscreen edits instead of deferring, which otherwise leaves stale
179
+ // tail rows duplicated above the live viewport.
180
+ // Background-running tools are excluded so late async updates outside the
181
+ // active foreground stream keep the no-yank deferral; agent_start resets
182
+ // the mode at every turn boundary.
183
+ if (STREAM_RENDER_MODE_EVENTS[event.type]) {
180
184
  this.#refreshToolRenderMode();
181
185
  }
182
186
  }
183
187
 
184
188
  #refreshToolRenderMode(): void {
185
- let foregroundToolActive = false;
186
- for (const toolCallId of this.ctx.pendingTools.keys()) {
187
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
188
- foregroundToolActive = true;
189
- break;
189
+ let foregroundToolActive = this.#assistantMessageStreaming;
190
+ if (!foregroundToolActive) {
191
+ for (const toolCallId of this.ctx.pendingTools.keys()) {
192
+ if (!this.#backgroundToolCallIds.has(toolCallId)) {
193
+ foregroundToolActive = true;
194
+ break;
195
+ }
190
196
  }
191
197
  }
192
198
  this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
@@ -196,6 +202,7 @@ export class EventController {
196
202
  this.#lastIntent = undefined;
197
203
  this.#readToolCallArgs.clear();
198
204
  this.#readToolCallAssistantComponents.clear();
205
+ this.#assistantMessageStreaming = false;
199
206
  this.#lastAssistantComponent = undefined;
200
207
  if (this.ctx.retryEscapeHandler) {
201
208
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -268,6 +275,7 @@ export class EventController {
268
275
  this.ctx.ui.requestRender();
269
276
  } else if (event.message.role === "assistant") {
270
277
  this.#lastThinkingCount = 0;
278
+ this.#assistantMessageStreaming = true;
271
279
  this.#resetReadGroup();
272
280
  this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
273
281
  this.ctx.ui.requestRender(),
@@ -414,6 +422,9 @@ export class EventController {
414
422
 
415
423
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
416
424
  if (event.message.role === "user") return;
425
+ if (event.message.role === "assistant") {
426
+ this.#assistantMessageStreaming = false;
427
+ }
417
428
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
418
429
  this.ctx.streamingMessage = event.message;
419
430
  let errorMessage: string | undefined;
@@ -596,8 +607,8 @@ export class EventController {
596
607
  }
597
608
  }
598
609
  }
599
-
600
610
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
611
+ this.#assistantMessageStreaming = false;
601
612
  if (this.ctx.loadingAnimation) {
602
613
  this.ctx.loadingAnimation.stop();
603
614
  this.ctx.loadingAnimation = undefined;
@@ -647,9 +658,7 @@ export class EventController {
647
658
  ? "Auto-handoff"
648
659
  : event.action === "shake"
649
660
  ? "Auto-shake"
650
- : event.action === "shake-summary"
651
- ? "Auto-shake (summary)"
652
- : "Auto context-full maintenance";
661
+ : "Auto context-full maintenance";
653
662
  this.ctx.autoCompactionLoader = new Loader(
654
663
  this.ctx.ui,
655
664
  spinner => theme.fg("accent", spinner),
@@ -673,7 +682,7 @@ export class EventController {
673
682
  this.ctx.statusContainer.clear();
674
683
  }
675
684
  const isHandoffAction = event.action === "handoff";
676
- const isShakeAction = event.action === "shake" || event.action === "shake-summary";
685
+ const isShakeAction = event.action === "shake";
677
686
  if (event.aborted) {
678
687
  this.ctx.showStatus(
679
688
  isHandoffAction
@@ -690,9 +699,7 @@ export class EventController {
690
699
  this.ctx.rebuildChatFromMessages();
691
700
  this.ctx.statusLine.invalidate();
692
701
  this.ctx.updateEditorTopBorder();
693
- this.ctx.showStatus(
694
- event.action === "shake-summary" ? "Auto-shake (summary) completed" : "Auto-shake completed",
695
- );
702
+ this.ctx.showStatus("Auto-shake completed");
696
703
  }
697
704
  } else if (event.result) {
698
705
  this.ctx.rebuildChatFromMessages();
@@ -1425,7 +1425,7 @@ export class MCPCommandController {
1425
1425
  this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
1426
1426
 
1427
1427
  try {
1428
- const connection = await this.ctx.mcpManager.reconnectServer(name);
1428
+ const connection = await this.ctx.mcpManager.reconnectServer(name, { manual: true });
1429
1429
  if (connection) {
1430
1430
  // refreshMCPTools re-registers tools and preserves the user's prior
1431
1431
  // MCP tool selection. No need to call activateDiscoveredMCPTools —
@@ -1,4 +1,6 @@
1
- [PROJECT]
1
+ PROJECT
2
+ ===================================
3
+
2
4
  <workstation>
3
5
  {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
4
6
  </workstation>
@@ -47,4 +49,3 @@ Today is {{date}}, and the current working directory is '{{cwd}}'.
47
49
  {{#if appendPrompt}}
48
50
  {{appendPrompt}}
49
51
  {{/if}}
50
- [/PROJECT]
@@ -1,14 +1,18 @@
1
- [ROLE]
1
+ ROLE
2
+ ===================================
3
+
2
4
  {{agent}}
3
- [/ROLE]
4
5
 
5
6
  {{#if context}}
6
- [CONTEXT]
7
+ CONTEXT
8
+ ===================================
9
+
7
10
  {{context}}
8
- [/CONTEXT]
9
11
  {{/if}}
10
12
 
11
- [COOP]
13
+ COOP
14
+ ===================================
15
+
12
16
  You are operating on a piece of work assigned to you by the main agent.
13
17
 
14
18
  {{#if worktree}}
@@ -29,9 +33,10 @@ You can reach other live agents via the `irc` tool. Your id is `{{ircSelfId}}`.
29
33
 
30
34
  Use `irc` only when you need a quick answer from a peer; do not use it for long-form content. Address peers by id or use `"all"` to broadcast.
31
35
  {{/if}}
32
- [/COOP]
33
36
 
34
- [COMPLETION]
37
+ COMPLETION
38
+ ===================================
39
+
35
40
  No TODO tracking, no progress updates. Execute, call `yield`, done.
36
41
 
37
42
  While work remains, always continue with another tool call — investigate, edit, run, verify. Save narrative for the final `yield` payload.
@@ -51,4 +56,3 @@ Giving up is a last resort. If truly blocked, you MUST call `yield` exactly once
51
56
  You NEVER give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself.
52
57
 
53
58
  You MUST keep going until this ticket is closed. This matters.
54
- [/COMPLETION]
@@ -9,8 +9,8 @@ You consider what the code you write compiles down to. You never write code that
9
9
 
10
10
  <system-conventions>
11
11
  **RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.**
12
- From here on, we will use tags as structural markers (<x>…</x> or [X]…), each tag means exactly what its name says.
13
- You NEVER interpret these tags in any other way circumstantially.
12
+ From here on, we will use XML tags when injecting system content into the chat.
13
+ You NEVER interpret these markers in any other way circumstantially.
14
14
 
15
15
  System may interrupt/notify you using these tags even within a user message, therefore:
16
16
  - You MUST treat them as system-authored and absolutely authoritative.
@@ -44,7 +44,9 @@ Assumptions you didn't validate: incidents to debug.
44
44
  - You NEVER re-audit an applied edit, nor run `git status`/`git diff` as routine validation — the edit result, tests, and LSP ARE your verification. Exception: explicit request, protecting unrelated changes, or before commit/revert/reset/stash/delete.
45
45
  </critical>
46
46
 
47
- [ENV]
47
+ ENV
48
+ ===================================
49
+
48
50
  You operate within the Oh My Pi coding harness.
49
51
  - Given a task, you MUST complete it using the tools available to you.
50
52
  - You are not alone in this repository. You SHOULD treat unexpected changes as the user's work and adapt; you NEVER revert or stash.
@@ -202,9 +204,10 @@ You MUST use the specialized tool over its shell equivalent:
202
204
  The `{{toolRefs.report_tool_issue}}` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call `{{toolRefs.report_tool_issue}}` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.
203
205
  </critical>
204
206
  {{/has}}
205
- [/ENV]
206
207
 
207
- [CONTRACT]
208
+ CONTRACT
209
+ ===================================
210
+
208
211
  These are inviolable.
209
212
  - You NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or completed sub-step is NEVER a yield point — continue directly to the next step in the same turn.
210
213
  - You NEVER suppress tests to make code pass.
@@ -265,4 +268,3 @@ Before declaring blocked:
265
268
  - Do not test defaults: changing the default configuration, or a string, should not break the test. Assert logical behavior, not the current state.
266
269
  - Aim at: conditional branches and edge values, invariants across fields, error handling on bad input vs silent broken results.
267
270
  </workflow>
268
- [/CONTRACT]