@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3

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 (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -0,0 +1,4 @@
1
+ declare module "omp-legacy-pi-modules" {
2
+ /** Host package namespaces retained by the compiled binary for legacy extensions. */
3
+ export const BUNDLED_PI_MODULES: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
4
+ }
@@ -16,7 +16,8 @@ import * as path from "node:path";
16
16
  import { promisify } from "node:util";
17
17
  import { gunzip } from "node:zlib";
18
18
  import { Glob } from "bun";
19
- import docsEmbed from "./docs-index.generated.txt";
19
+
20
+ const docsEmbed = process.env.PI_DOCS_EMBED ?? "";
20
21
 
21
22
  const gunzipAsync = promisify(gunzip);
22
23
 
@@ -72,7 +72,7 @@ export class SkillProtocolHandler implements ProtocolHandler {
72
72
  throw new Error("Path traversal is not allowed");
73
73
  }
74
74
  } else {
75
- targetPath = skill.filePath;
75
+ targetPath = context?.pathOnly === true ? skill.baseDir : skill.filePath;
76
76
  }
77
77
 
78
78
  let stats: fsTypes.Stats;
@@ -0,0 +1,66 @@
1
+ import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
2
+ import { getDiagnosticsLedger } from "./diagnostics-ledger";
3
+ import type { FileDiagnosticsResult, WritethroughDeferredHandle } from "./index";
4
+
5
+ /** Coordinates late LSP diagnostics for one mutation tool instance. */
6
+ export class DeferredDiagnostics {
7
+ readonly #pendingFetches = new Map<string, AbortController>();
8
+ readonly #fallbackVersions = new Map<string, number>();
9
+
10
+ constructor(
11
+ private readonly session: ToolSession,
12
+ private readonly deduplicate: boolean,
13
+ ) {}
14
+
15
+ /** Begin a file mutation and return the handle consumed by LSP writethrough. */
16
+ begin(path: string): WritethroughDeferredHandle {
17
+ const existing = this.#pendingFetches.get(path);
18
+ if (existing) {
19
+ existing.abort();
20
+ this.#pendingFetches.delete(path);
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ const mutationVersion = this.#bumpVersion(path);
25
+ return {
26
+ onDeferredDiagnostics: diagnostics => {
27
+ this.#pendingFetches.delete(path);
28
+ this.#inject(path, diagnostics, mutationVersion);
29
+ },
30
+ signal: controller.signal,
31
+ finalize: diagnostics => {
32
+ if (!diagnostics) {
33
+ this.#pendingFetches.set(path, controller);
34
+ } else {
35
+ controller.abort();
36
+ }
37
+ },
38
+ };
39
+ }
40
+
41
+ #inject(path: string, diagnostics: FileDiagnosticsResult, mutationVersion: number): void {
42
+ const effective = this.deduplicate ? getDiagnosticsLedger(this.session).reduce(path, diagnostics) : diagnostics;
43
+ if (this.deduplicate && effective.messages.length === 0) return;
44
+
45
+ const entry: DeferredDiagnosticsEntry = {
46
+ path,
47
+ summary: effective.summary ?? "",
48
+ messages: effective.messages ?? [],
49
+ errored: effective.errored,
50
+ isStale: () => this.#version(path) !== mutationVersion,
51
+ };
52
+ this.session.queueDeferredDiagnostics?.(entry);
53
+ }
54
+
55
+ #bumpVersion(path: string): number {
56
+ if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
57
+ const next = (this.#fallbackVersions.get(path) ?? 0) + 1;
58
+ this.#fallbackVersions.set(path, next);
59
+ return next;
60
+ }
61
+
62
+ #version(path: string): number {
63
+ if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
64
+ return this.#fallbackVersions.get(path) ?? 0;
65
+ }
66
+ }
package/src/main.ts CHANGED
@@ -78,9 +78,10 @@ import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking"
78
78
  import type { LspStartupServerInfo } from "./tools";
79
79
  import {
80
80
  getChangelogPath,
81
- getNewEntries,
82
81
  parseChangelog,
82
+ parseChangelogVersion,
83
83
  readLastChangelogVersion,
84
+ selectStartupChangelog,
84
85
  writeLastChangelogVersion,
85
86
  } from "./utils/changelog";
86
87
  import { EventBus } from "./utils/event-bus";
@@ -610,6 +611,11 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
610
611
  }
611
612
 
612
613
  const lastVersion = await readLastChangelogVersion();
614
+ const parsedLastVersion = parseChangelogVersion(lastVersion);
615
+ if (!parsedLastVersion) {
616
+ await writeLastChangelogVersion(VERSION);
617
+ return undefined;
618
+ }
613
619
  if (lastVersion === VERSION) {
614
620
  // Steady state: user already saw the current version's changelog. Skip the file read + parse.
615
621
  return undefined;
@@ -617,18 +623,12 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
617
623
 
618
624
  const changelogPath = getChangelogPath();
619
625
  const entries = await parseChangelog(changelogPath);
620
-
621
- if (!lastVersion) {
622
- if (entries.length > 0) {
623
- await writeLastChangelogVersion(VERSION);
624
- return entries.map(e => e.content).join("\n\n");
625
- }
626
- } else {
627
- const newEntries = getNewEntries(entries, lastVersion);
628
- if (newEntries.length > 0) {
629
- await writeLastChangelogVersion(VERSION);
630
- return newEntries.map(e => e.content).join("\n\n");
631
- }
626
+ const startupChangelog = selectStartupChangelog(entries, lastVersion, VERSION);
627
+ if (startupChangelog.persistCurrentVersion) {
628
+ await writeLastChangelogVersion(VERSION);
629
+ }
630
+ if (startupChangelog.markdown) {
631
+ return startupChangelog.markdown;
632
632
  }
633
633
 
634
634
  return undefined;
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { describe, expect, it, spyOn } from "bun:test";
2
2
 
3
3
  import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
4
4
 
@@ -56,6 +56,50 @@ describe("resolveStdioSpawnCommand", () => {
56
56
  });
57
57
  });
58
58
 
59
+ describe("StdioTransport.connect", () => {
60
+ it("passes argv as Bun.spawn's first argument and process options as the second", async () => {
61
+ const cwd = process.cwd();
62
+ const envValue = "stdio-spawn-shape";
63
+ const argv = [process.execPath, "-e", "process.exit(0)"];
64
+ const transport = new StdioTransport({
65
+ command: argv[0],
66
+ args: argv.slice(1),
67
+ cwd,
68
+ env: {
69
+ OMP_STDIO_SPAWN_SHAPE: envValue,
70
+ },
71
+ });
72
+ const spawnSpy = spyOn(Bun, "spawn");
73
+
74
+ try {
75
+ await transport.connect();
76
+
77
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
78
+ const call = spawnSpy.mock.calls[0];
79
+ if (!call) throw new Error("expected StdioTransport.connect() to spawn exactly one subprocess");
80
+
81
+ const [spawnArgv, spawnOptions] = call;
82
+ expect(spawnArgv).toEqual(argv);
83
+ expect(spawnOptions).toEqual(
84
+ expect.objectContaining({
85
+ cwd,
86
+ detached: !(process.platform === "darwin" || process.platform === "win32"),
87
+ env: expect.objectContaining({
88
+ OMP_STDIO_SPAWN_SHAPE: envValue,
89
+ }),
90
+ stderr: "pipe",
91
+ stdin: "pipe",
92
+ stdout: "pipe",
93
+ windowsHide: process.platform === "win32" ? expect.any(Boolean) : undefined,
94
+ }),
95
+ );
96
+ } finally {
97
+ await transport.close();
98
+ spawnSpy.mockRestore();
99
+ }
100
+ });
101
+ });
102
+
59
103
  // Regression for #3945: request() awaited stdin.write/flush, so a child that
60
104
  // stops draining stdin would park the async fn past the timeout timer and past
61
105
  // `return promise`, orphaning the deferred rejection and hanging the caller
@@ -8,7 +8,7 @@
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
10
  import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
11
- import { type Subprocess, spawn } from "bun";
11
+ import type { Subprocess } from "bun";
12
12
  import { hostHasInheritableConsole } from "../../eval/py/spawn-options";
13
13
  import type {
14
14
  JsonRpcError,
@@ -376,8 +376,11 @@ export class StdioTransport implements MCPTransport {
376
376
  // macOS stays attached so TCC can prompt for Apple Events automation;
377
377
  // Windows stays attached, and only hides the child when the host has no
378
378
  // console to share. See `StdioSpawnCommand`.
379
- this.#process = spawn({
380
- cmd: spawnCommand.cmd,
379
+ // Keep this on Bun's argv-first overload. The eval JS kernel path that
380
+ // triggers macOS Apple Events TCC prompts uses the same shape; the
381
+ // one-object `{ cmd }` overload timed out before prompting for `mcpbridge`
382
+ // even with `detached: false` (#5085).
383
+ this.#process = Bun.spawn(spawnCommand.cmd, {
381
384
  cwd,
382
385
  env,
383
386
  stdin: "pipe",
@@ -129,6 +129,15 @@ type PromptLifecycleError = Error & { readonly code: "ACP_SESSION_CLOSED" };
129
129
  type PromptTurnState = {
130
130
  cancelRequested: boolean;
131
131
  settled: boolean;
132
+ /**
133
+ * Delivery of streamed assistant `error` chunks this turn (the mapper
134
+ * surfaces them as `agent_message_chunk`s). Resolves `true` once at least
135
+ * one error chunk reached the client — the `agent_end` error fallback in
136
+ * {@link AcpAgent##flushUnreportedTurnError} awaits it and stays silent on
137
+ * success, so a fallback racing an in-flight delivery can neither duplicate
138
+ * the error nor drop it when delivery fails.
139
+ */
140
+ errorTextDelivery: Promise<boolean> | undefined;
132
141
  /**
133
142
  * `abort()` is in-flight (or its bounded-timeout race). `undefined` while the turn is
134
143
  * running normally and after cleanup completes. The turn occupies `record.promptTurn`
@@ -684,6 +693,7 @@ export class AcpAgent implements Agent {
684
693
  record.promptTurn = {
685
694
  cancelRequested: false,
686
695
  settled: false,
696
+ errorTextDelivery: undefined,
687
697
  cleanup: undefined,
688
698
  usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
689
699
  unsubscribe: undefined,
@@ -1200,6 +1210,10 @@ export class AcpAgent implements Agent {
1200
1210
  imageDataCache.set(key, resolved);
1201
1211
  return resolved;
1202
1212
  };
1213
+ const streamedAssistantError =
1214
+ event.type === "message_update" &&
1215
+ event.message.role === "assistant" &&
1216
+ event.assistantMessageEvent.type === "error";
1203
1217
  for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, record.session.sessionId, {
1204
1218
  getMessageId: message => this.#getLiveMessageId(record, message),
1205
1219
  getMessageProgress: message => this.#getLiveMessageProgress(record, message),
@@ -1207,7 +1221,18 @@ export class AcpAgent implements Agent {
1207
1221
  cwd: record.session.sessionManager.getCwd(),
1208
1222
  resolveImageData: resolveImageDataForAcp,
1209
1223
  })) {
1210
- await this.#connection.sessionUpdate(notification);
1224
+ const delivery = this.#connection.sessionUpdate(notification);
1225
+ if (streamedAssistantError) {
1226
+ // Resolves true only once the error chunk actually reached the
1227
+ // client — a failed delivery keeps the agent_end fallback armed.
1228
+ const outcome = delivery.then(
1229
+ () => true,
1230
+ () => false,
1231
+ );
1232
+ const prior = promptTurn.errorTextDelivery;
1233
+ promptTurn.errorTextDelivery = prior ? Promise.all([prior, outcome]).then(([a, b]) => a || b) : outcome;
1234
+ }
1235
+ await delivery;
1211
1236
  }
1212
1237
  if (event.type === "tool_execution_end") {
1213
1238
  record.toolArgsById.delete(event.toolCallId);
@@ -1216,6 +1241,7 @@ export class AcpAgent implements Agent {
1216
1241
 
1217
1242
  if (event.type === "agent_end") {
1218
1243
  await this.#flushMissedFinalAssistantText(record, event);
1244
+ await this.#flushUnreportedTurnError(record, event);
1219
1245
  await this.#emitEndOfTurnUpdates(record);
1220
1246
  await this.#waitForAcpPromptIdle(record);
1221
1247
  record.liveMessageId = undefined;
@@ -1272,6 +1298,44 @@ export class AcpAgent implements Agent {
1272
1298
  });
1273
1299
  }
1274
1300
 
1301
+ /**
1302
+ * Surface a turn-fatal provider error that never reached the client. A
1303
+ * request that fails before streaming any assistant events — e.g. GitHub
1304
+ * Copilot's `HTTP 400 model_not_supported` after retries — emits only
1305
+ * `agent_end` with an empty assistant message carrying `errorMessage`
1306
+ * (`Agent#runLoop`'s catch), so no `message_update`/`message_end` ever maps
1307
+ * to a session update and the client sees the turn end silently. Errors
1308
+ * that did stream are tracked via {@link PromptTurnState.errorTextDelivery};
1309
+ * the fallback awaits that delivery and re-sends only when it failed.
1310
+ */
1311
+ async #flushUnreportedTurnError(
1312
+ record: ManagedSessionRecord,
1313
+ event: Extract<AgentSessionEvent, { type: "agent_end" }>,
1314
+ ): Promise<void> {
1315
+ const streamedDelivery = record.promptTurn?.errorTextDelivery;
1316
+ if (streamedDelivery && (await streamedDelivery)) {
1317
+ return;
1318
+ }
1319
+ const lastAssistant = [...event.messages]
1320
+ .reverse()
1321
+ .find((message): message is AssistantMessage => message.role === "assistant");
1322
+ if (lastAssistant?.stopReason !== "error") {
1323
+ return;
1324
+ }
1325
+ const errorMessage = lastAssistant.errorMessage;
1326
+ if (!errorMessage || isSilentAbort(lastAssistant)) {
1327
+ return;
1328
+ }
1329
+ await this.#connection.sessionUpdate({
1330
+ sessionId: record.session.sessionId,
1331
+ update: {
1332
+ sessionUpdate: "agent_message_chunk",
1333
+ content: { type: "text", text: errorMessage },
1334
+ messageId: record.liveMessageId ?? crypto.randomUUID(),
1335
+ },
1336
+ });
1337
+ }
1338
+
1275
1339
  async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1276
1340
  for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1277
1341
  await record.session.waitForIdle();
@@ -284,6 +284,11 @@ function mapAssistantMessageUpdate(
284
284
  case "error":
285
285
  sessionUpdate = "agent_message_chunk";
286
286
  text = event.assistantMessageEvent.error.errorMessage ?? "Unknown error";
287
+ // The surfaced error is the message's visible text: keeps the
288
+ // message_end / agent_end fallbacks from emitting again.
289
+ if (text.length > 0 && progress) {
290
+ progress.textEmitted = true;
291
+ }
287
292
  break;
288
293
  default:
289
294
  return [];
@@ -14,6 +14,17 @@ export function createAcpConnection(
14
14
  }
15
15
 
16
16
  export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never> {
17
+ // Humans who run `omp acp` by hand see a silent process and assume it is
18
+ // broken (stdout is the JSON-RPC transport, so nothing may be printed
19
+ // there). When stdin is a TTY no ACP client is attached — say so on stderr
20
+ // before the transport starts.
21
+ if (process.stdin.isTTY) {
22
+ process.stderr.write(
23
+ "omp acp: ACP server speaking JSON-RPC over stdio.\n" +
24
+ 'This command is meant to be spawned by an ACP client (e.g. Zed\'s "agent_servers" config), not run directly.\n' +
25
+ "Waiting for protocol frames on stdin; logs: ~/.omp/logs/\n",
26
+ );
27
+ }
17
28
  const input = stream.Writable.toWeb(process.stdout);
18
29
  const output = stream.Readable.toWeb(process.stdin);
19
30
  const transport = ndJsonStream(input, output);
@@ -3,12 +3,14 @@ import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
+ import { visibleWidth } from "@oh-my-pi/pi-tui";
6
7
  import { Settings } from "../../../config/settings";
7
8
  import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
8
9
  import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
9
10
 
10
11
  // Strip SGR colors so assertions see visible text only.
11
- const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
12
+ const stripAnsi = (text: string): string => text.replace(/\x1b\[[0-9;]*m/g, "");
13
+ const strip = (lines: readonly string[]): string => lines.map(stripAnsi).join("\n");
12
14
 
13
15
  describe("resolveMovePath", () => {
14
16
  it("expands ~ to homedir", () => {
@@ -82,6 +84,19 @@ describe("MoveOverlay", () => {
82
84
  expect(text).toContain("Path:");
83
85
  });
84
86
 
87
+ it("renders every frame row at the assigned overlay width", () => {
88
+ const overlay = new MoveOverlay(cwd, () => {});
89
+ const lines = overlay.render(72);
90
+ const plainLines = lines.map(stripAnsi);
91
+
92
+ expect(lines.map(line => visibleWidth(line))).toEqual(Array(lines.length).fill(72));
93
+ expect(plainLines[0]!.endsWith(uiTheme.boxRound.topRight)).toBe(true);
94
+ expect(plainLines.at(-1)!.endsWith(uiTheme.boxRound.bottomRight)).toBe(true);
95
+ for (const line of plainLines.slice(1, -1)) {
96
+ expect(line.endsWith(uiTheme.boxRound.vertical)).toBe(true);
97
+ }
98
+ });
99
+
85
100
  it("lists child directories (excluding hidden and files) on empty input", () => {
86
101
  const overlay = new MoveOverlay(cwd, () => {});
87
102
  const text = strip(overlay.render(80));
@@ -82,9 +82,9 @@ function previewLine(text: string | undefined): string {
82
82
  return first.length > PREVIEW_WIDTH ? `${first.slice(0, PREVIEW_WIDTH - 1)}…` : first;
83
83
  }
84
84
 
85
- /** Default when the set is empty or exactly read/grep/glob; else the available-ordered subset. */
85
+ /** Omitted means default read/grep/glob; an explicit empty set means no tools. */
86
86
  function commitTools(selected: ReadonlySet<string>, all: readonly string[]): string[] | undefined {
87
- if (selected.size === 0) return undefined;
87
+ if (selected.size === 0) return [];
88
88
  if (selected.size === ADVISOR_DEFAULT_TOOL_NAMES.size) {
89
89
  let matchesDefault = true;
90
90
  for (const name of ADVISOR_DEFAULT_TOOL_NAMES) {
@@ -98,6 +98,11 @@ function commitTools(selected: ReadonlySet<string>, all: readonly string[]): str
98
98
  return all.filter(name => selected.has(name));
99
99
  }
100
100
 
101
+ function formatAdvisorTools(tools: readonly string[] | undefined, emptyLabel: string): string {
102
+ if (tools === undefined) return "read, grep, glob (default)";
103
+ return tools.length > 0 ? tools.join(", ") : emptyLabel;
104
+ }
105
+
101
106
  /** Soft-wrap plain text to `width`, returning at least one (possibly empty) line. */
102
107
  function wrap(text: string, width: number): string[] {
103
108
  if (!text) return [""];
@@ -266,7 +271,7 @@ export class AdvisorConfigOverlayComponent implements Component {
266
271
 
267
272
  #advisorPreview(advisor: AdvisorConfig, bodyWidth: number): string[] {
268
273
  const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
269
- const tools = advisor.tools?.length ? advisor.tools.join(", ") : "read, grep, glob (default)";
274
+ const tools = formatAdvisorTools(advisor.tools, "no tools");
270
275
  const lines = [
271
276
  theme.bold(advisor.name || "(unnamed)"),
272
277
  "",
@@ -303,13 +308,16 @@ export class AdvisorConfigOverlayComponent implements Component {
303
308
  const advisor = doc.advisors[0];
304
309
  if (!advisor) return false;
305
310
  return (
306
- advisor.name === "default" && !advisor.model?.trim() && !advisor.tools?.length && !advisor.instructions?.trim()
311
+ advisor.name === "default" &&
312
+ !advisor.model?.trim() &&
313
+ advisor.tools === undefined &&
314
+ !advisor.instructions?.trim()
307
315
  );
308
316
  }
309
317
 
310
318
  #advisorSummary(advisor: AdvisorConfig): string {
311
319
  const model = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
312
- const tools = advisor.tools?.length ? advisor.tools.join(", ") : "(default: read/grep/glob)";
320
+ const tools = formatAdvisorTools(advisor.tools, "no tools");
313
321
  return `${model} · ${tools}`;
314
322
  }
315
323
 
@@ -384,7 +392,7 @@ export class AdvisorConfigOverlayComponent implements Component {
384
392
  return;
385
393
  }
386
394
  const modelDescription = advisor.model?.trim() || this.#defaultModelLabel || "advisor role default";
387
- const toolsDescription = advisor.tools?.length ? advisor.tools.join(", ") : "(default: read/grep/glob)";
395
+ const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
388
396
  const items: SelectItem[] = [
389
397
  { value: "name", label: "Name", description: advisor.name },
390
398
  { value: "model", label: "Model", description: modelDescription },
@@ -524,7 +532,7 @@ export class AdvisorConfigOverlayComponent implements Component {
524
532
  this.#setScreen(
525
533
  "tools",
526
534
  list,
527
- "Enter / click toggle · select Done or Esc to apply (empty or read/grep/glob = default)",
535
+ "Enter / click toggle · select Done or Esc to apply (empty = no tools; read/grep/glob = default)",
528
536
  );
529
537
  }
530
538
 
@@ -25,7 +25,6 @@ interface DirEntry {
25
25
  }
26
26
 
27
27
  const MAX_RESULTS = 15;
28
- const OVERLAY_WIDTH = 68;
29
28
 
30
29
  /** TTL for the directory listing cache (ms). */
31
30
  const DIR_CACHE_TTL = 500;
@@ -230,8 +229,8 @@ export class MoveOverlay implements Component, Focusable {
230
229
  }
231
230
  }
232
231
 
233
- render(_width: number): readonly string[] {
234
- const w = OVERLAY_WIDTH;
232
+ render(width: number): readonly string[] {
233
+ const w = width;
235
234
  const lines: string[] = [];
236
235
 
237
236
  lines.push(topBorder(w, "Move to directory"));
@@ -275,6 +275,7 @@ export class StatusLineComponent implements Component {
275
275
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
276
276
  #loopModeStatus: { enabled: boolean } | null = null;
277
277
  #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
278
+ #vibeModeStatus: { enabled: boolean } | null = null;
278
279
  #collabStatus: CollabStatus | null = null;
279
280
  #focusedAgentId: string | undefined;
280
281
  #activeRepoCache: ActiveRepoCache | undefined;
@@ -503,6 +504,10 @@ export class StatusLineComponent implements Component {
503
504
  this.#goalModeStatus = status ?? null;
504
505
  }
505
506
 
507
+ setVibeModeStatus(status: { enabled: boolean } | undefined): void {
508
+ this.#vibeModeStatus = status ?? null;
509
+ }
510
+
506
511
  setCollabStatus(status: CollabStatus | null): void {
507
512
  this.#collabStatus = status;
508
513
  }
@@ -1047,6 +1052,7 @@ export class StatusLineComponent implements Component {
1047
1052
  planMode: this.#planModeStatus,
1048
1053
  loopMode: this.#loopModeStatus,
1049
1054
  goalMode: this.#goalModeStatus,
1055
+ vibeMode: this.#vibeModeStatus,
1050
1056
  collab: this.#collabStatus,
1051
1057
  usageStats,
1052
1058
  contextPercent,
@@ -213,6 +213,12 @@ const modeSegment: StatusLineSegment = {
213
213
  return renderGoalMode(ctx, goal);
214
214
  }
215
215
 
216
+ const vibe = ctx.vibeMode;
217
+ if (vibe?.enabled) {
218
+ const content = withIcon(theme.icon.agents, "Vibe");
219
+ return { content: theme.fg("accent", content), visible: true };
220
+ }
221
+
216
222
  const loop = ctx.loopMode;
217
223
  if (loop?.enabled) {
218
224
  const content = withIcon(theme.icon.loop, "Loop");
@@ -67,6 +67,9 @@ export interface SegmentContext {
67
67
  enabled: boolean;
68
68
  paused: boolean;
69
69
  } | null;
70
+ vibeMode: {
71
+ enabled: boolean;
72
+ } | null;
70
73
  collab: CollabStatus | null;
71
74
  // Cached values for performance (computed once per render)
72
75
  usageStats: {
@@ -47,7 +47,12 @@ import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-o
47
47
  import { outputMeta } from "../../tools/output-meta";
48
48
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
49
49
  import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
50
- import { getChangelogPath, parseChangelog } from "../../utils/changelog";
50
+ import {
51
+ getChangelogPath,
52
+ parseChangelog,
53
+ RECENT_CHANGELOG_ENTRY_LIMIT,
54
+ renderChangelogEntries,
55
+ } from "../../utils/changelog";
51
56
  import { copyToClipboard } from "../../utils/clipboard";
52
57
  import { openPath } from "../../utils/open";
53
58
  import { setSessionTerminalTitle } from "../../utils/title-generator";
@@ -487,16 +492,9 @@ export class CommandController {
487
492
  async handleChangelogCommand(showFull = false): Promise<void> {
488
493
  const changelogPath = getChangelogPath();
489
494
  const allEntries = await parseChangelog(changelogPath);
490
- // Default to showing only the latest 3 versions unless --full is specified
491
- // allEntries comes from parseChangelog with newest first, reverse to show oldest->newest
492
- const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
495
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
493
496
  const changelogMarkdown =
494
- entriesToShow.length > 0
495
- ? [...entriesToShow]
496
- .reverse()
497
- .map(e => e.content)
498
- .join("\n\n")
499
- : "No changelog entries found.";
497
+ entriesToShow.length > 0 ? renderChangelogEntries(entriesToShow).markdown : "No changelog entries found.";
500
498
  const title = showFull ? "Full Changelog" : "Recent Changes";
501
499
  const hint = showFull
502
500
  ? ""