@oh-my-pi/pi-coding-agent 15.0.1 → 15.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 (47) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +8 -8
  3. package/src/commands/commit.ts +10 -0
  4. package/src/config/model-registry.ts +31 -1
  5. package/src/config/settings-schema.ts +11 -0
  6. package/src/discovery/claude-plugins.ts +19 -7
  7. package/src/eval/py/runner.py +42 -11
  8. package/src/eval/py/runtime.ts +1 -0
  9. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  10. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  11. package/src/hashline/input.ts +2 -1
  12. package/src/hashline/parser.ts +27 -3
  13. package/src/internal-urls/docs-index.generated.ts +8 -8
  14. package/src/internal-urls/router.ts +8 -0
  15. package/src/internal-urls/types.ts +21 -0
  16. package/src/lsp/config.ts +15 -6
  17. package/src/lsp/defaults.json +6 -2
  18. package/src/modes/acp/acp-agent.ts +248 -50
  19. package/src/modes/components/status-line/segments.ts +38 -4
  20. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  21. package/src/modes/rpc/host-uris.ts +235 -0
  22. package/src/modes/rpc/rpc-mode.ts +27 -1
  23. package/src/modes/rpc/rpc-types.ts +57 -0
  24. package/src/modes/runtime-init.ts +2 -1
  25. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  26. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  27. package/src/modes/theme/theme.ts +6 -0
  28. package/src/prompts/tools/github.md +4 -4
  29. package/src/prompts/tools/hashline.md +22 -26
  30. package/src/prompts/tools/read.md +55 -37
  31. package/src/task/discovery.ts +5 -2
  32. package/src/task/executor.ts +2 -1
  33. package/src/tools/bash-command-fixup.ts +47 -0
  34. package/src/tools/bash.ts +39 -15
  35. package/src/tools/browser/render.ts +2 -2
  36. package/src/tools/eval.ts +10 -2
  37. package/src/tools/gh.ts +37 -4
  38. package/src/tools/job.ts +16 -7
  39. package/src/tools/output-meta.ts +26 -0
  40. package/src/tools/read.ts +32 -4
  41. package/src/tools/ssh.ts +3 -2
  42. package/src/tools/write.ts +20 -0
  43. package/src/web/search/providers/anthropic.ts +5 -0
  44. package/src/web/search/providers/exa.ts +3 -0
  45. package/src/web/search/providers/gemini.ts +5 -0
  46. package/src/web/search/providers/jina.ts +5 -2
  47. package/src/web/search/providers/zai.ts +5 -2
@@ -50,6 +50,14 @@ export class InternalUrlRouter {
50
50
  this.#handlers.set(handler.scheme.toLowerCase(), handler);
51
51
  }
52
52
 
53
+ unregister(scheme: string): boolean {
54
+ return this.#handlers.delete(scheme.toLowerCase());
55
+ }
56
+
57
+ getHandler(scheme: string): ProtocolHandler | undefined {
58
+ return this.#handlers.get(scheme.toLowerCase());
59
+ }
60
+
53
61
  canHandle(input: string): boolean {
54
62
  const match = input.match(/^([a-z][a-z0-9+.-]*):\/\//i);
55
63
  if (!match) return false;
@@ -63,6 +63,18 @@ export interface ResolveContext {
63
63
  signal?: AbortSignal;
64
64
  }
65
65
 
66
+ /**
67
+ * Caller context for write operations dispatched to host-owned URI handlers.
68
+ * Mirrors {@link ResolveContext} so handlers that share read/write state can
69
+ * accept the same shape.
70
+ */
71
+ export interface WriteContext {
72
+ /** Working directory of the calling session. */
73
+ cwd?: string;
74
+ /** Caller's abort signal. */
75
+ signal?: AbortSignal;
76
+ }
77
+
66
78
  /**
67
79
  * Handler for a specific internal URL scheme (e.g., agent://, memory://, skill://, mcp://).
68
80
  */
@@ -86,4 +98,13 @@ export interface ProtocolHandler {
86
98
  * @throws Error with user-friendly message if resolution fails
87
99
  */
88
100
  resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource>;
101
+ /**
102
+ * Optional write hook. When present, the write tool dispatches
103
+ * `write(url, content)` to this handler instead of writing to a filesystem
104
+ * path. The handler is responsible for any persistence and validation.
105
+ *
106
+ * Handlers that omit this method are treated as read-only; the write tool
107
+ * surfaces a clear "not writable" error when invoked against them.
108
+ */
109
+ write?(url: InternalUrl, content: string, context?: WriteContext): Promise<void>;
89
110
  }
package/src/lsp/config.ts CHANGED
@@ -154,16 +154,25 @@ function applyRuntimeDefaults(servers: Record<string, ServerConfig>): Record<str
154
154
  * Check if any root marker file exists in the directory
155
155
  */
156
156
  export function hasRootMarkers(cwd: string, markers: string[]): boolean {
157
+ let entries: string[] | null = null;
157
158
  for (const marker of markers) {
158
- // Handle glob-like patterns (e.g., "*.cabal")
159
+ // Handle glob-like patterns (e.g., "*.cabal"). Root markers live at the
160
+ // project root, so a one-level readdir is sufficient — and avoids
161
+ // Bun.Glob descending into node_modules for patterns like "**/*.cabal".
159
162
  if (marker.includes("*")) {
160
- try {
161
- const scan = new Bun.Glob(marker).scanSync({ cwd, onlyFiles: false });
162
- for (const _ of scan) {
163
+ if (entries === null) {
164
+ try {
165
+ entries = fs.readdirSync(cwd);
166
+ } catch {
167
+ entries = [];
168
+ logger.warn("Failed to list directory for glob root marker.", { marker, cwd });
169
+ }
170
+ }
171
+ const glob = new Bun.Glob(marker);
172
+ for (const entry of entries) {
173
+ if (glob.match(entry)) {
163
174
  return true;
164
175
  }
165
- } catch {
166
- logger.warn("Failed to resolve glob root marker.", { marker, cwd });
167
176
  }
168
177
  continue;
169
178
  }
@@ -5,7 +5,11 @@
5
5
  "fileTypes": [".rs"],
6
6
  "rootMarkers": ["Cargo.toml", "rust-analyzer.toml"],
7
7
  "initOptions": {},
8
- "settings": {},
8
+ "settings": {
9
+ "rust-analyzer": {
10
+ "checkOnSave": false
11
+ }
12
+ },
9
13
  "capabilities": {
10
14
  "flycheck": true,
11
15
  "ssr": true,
@@ -424,7 +428,7 @@
424
428
  "args": ["server"],
425
429
  "fileTypes": [".md", ".markdown"],
426
430
  "rootMarkers": [".marksman.toml", ".git"],
427
- "warmupTimeoutMs": 15000
431
+ "warmupTimeoutMs": 2000
428
432
  },
429
433
  "texlab": {
430
434
  "command": "texlab",
@@ -9,6 +9,9 @@ import {
9
9
  type ClientCapabilities,
10
10
  type CloseSessionRequest,
11
11
  type CloseSessionResponse,
12
+ type CreateElicitationResponse,
13
+ type ElicitationContentValue,
14
+ type ElicitationPropertySchema,
12
15
  type ForkSessionRequest,
13
16
  type ForkSessionResponse,
14
17
  type InitializeRequest,
@@ -44,8 +47,9 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
44
47
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
45
48
  import { Settings } from "../../config/settings";
46
49
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
47
- import type { ExtensionUIContext } from "../../extensibility/extensions";
50
+ import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
48
51
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
52
+ import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
49
53
  import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
50
54
  import { loadSlashCommands } from "../../extensibility/slash-commands";
51
55
  import { MCPManager } from "../../mcp/manager";
@@ -89,6 +93,11 @@ type AgentImageContent = {
89
93
  mimeType: string;
90
94
  };
91
95
 
96
+ type PromptQueueState = {
97
+ promise: Promise<void>;
98
+ release: (() => void) | undefined;
99
+ };
100
+
92
101
  type PromptTurnState = {
93
102
  userMessageId: string;
94
103
  cancelRequested: boolean;
@@ -97,12 +106,14 @@ type PromptTurnState = {
97
106
  unsubscribe: (() => void) | undefined;
98
107
  resolve: (value: PromptResponse) => void;
99
108
  reject: (reason?: unknown) => void;
109
+ promise: Promise<PromptResponse>;
100
110
  };
101
111
 
102
112
  type ManagedSessionRecord = {
103
113
  session: AgentSession;
104
114
  mcpManager: MCPManager | undefined;
105
115
  promptTurn: PromptTurnState | undefined;
116
+ promptQueue: PromptQueueState;
106
117
  liveMessageId: string | undefined;
107
118
  liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
108
119
  extensionsConfigured: boolean;
@@ -138,35 +149,185 @@ type MCPSourceMap = {
138
149
 
139
150
  type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
140
151
 
141
- const acpExtensionUiContext: ExtensionUIContext = {
142
- select: async () => undefined,
143
- confirm: async () => false,
144
- input: async () => undefined,
145
- notify: (message, type) => {
146
- logger.debug("ACP extension notification", { message, type });
147
- },
148
- onTerminalInput: () => () => {},
149
- setStatus: () => {},
150
- setWorkingMessage: () => {},
151
- setWidget: () => {},
152
- setFooter: () => {},
153
- setHeader: () => {},
154
- setTitle: () => {},
155
- custom: async () => undefined as never,
156
- pasteToEditor: () => {},
157
- setEditorText: () => {},
158
- getEditorText: () => "",
159
- editor: async () => undefined,
160
- setEditorComponent: () => {},
161
- get theme() {
162
- return theme;
163
- },
164
- getAllThemes: async () => [],
165
- getTheme: async () => undefined,
166
- setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
167
- getToolsExpanded: () => false,
168
- setToolsExpanded: () => {},
169
- };
152
+ /**
153
+ * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation`
154
+ * surface. Skills/extensions ask for one value at a time (a chosen option, a
155
+ * confirmation, a piece of text), so every elicitation here uses a one-property
156
+ * `value` schema; the caller narrows the resulting `ElicitationContentValue`
157
+ * back to its concrete primitive type.
158
+ *
159
+ * `dialogOptions.signal` short-circuits the elicitation if it is already
160
+ * aborted and races the in-flight request against the abort event. The SDK
161
+ * exposes no `cancel_elicitation` surface for form-mode elicitations
162
+ * (`unstable_completeElicitation` is URL-mode only), so the ACP request itself
163
+ * keeps running on the client side until the user dismisses it — but
164
+ * resolving the local promise unblocks the caller (matches the RPC mode
165
+ * pattern in `requestRpcEditor`). The abort listener is removed once the
166
+ * elicitation settles so that callers which reuse the same signal across many
167
+ * elicitations (e.g. `ask` multi-select loops) don't accumulate listeners and
168
+ * trip Node's `MaxListeners` warning.
169
+ *
170
+ * `dialogOptions.timeout` mirrors `RpcExtensionUIContext.#createDialogPromise`:
171
+ * when the timer fires before the client responds, `onTimeout` is invoked and
172
+ * the caller's promise resolves to the stub fallback. Late SDK responses that
173
+ * arrive after abort/timeout — both rejections and successful `accept`s —
174
+ * are dropped silently (no `logger.warn`) to keep operator logs clean.
175
+ */
176
+ async function elicitFromAcpClient(
177
+ connection: AgentSideConnection,
178
+ sessionId: string,
179
+ method: "select" | "confirm" | "input",
180
+ message: string,
181
+ property: ElicitationPropertySchema,
182
+ dialogOptions: ExtensionUIDialogOptions | undefined,
183
+ ): Promise<ElicitationContentValue | undefined> {
184
+ const signal = dialogOptions?.signal;
185
+ if (signal?.aborted) {
186
+ return undefined;
187
+ }
188
+ const { promise, resolve } = Promise.withResolvers<CreateElicitationResponse | undefined>();
189
+ let settled = false;
190
+ let timeoutId: NodeJS.Timeout | undefined;
191
+ const finish = (value: CreateElicitationResponse | undefined) => {
192
+ if (settled) return;
193
+ settled = true;
194
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
195
+ signal?.removeEventListener("abort", onAbort);
196
+ resolve(value);
197
+ };
198
+ const onAbort = () => finish(undefined);
199
+ signal?.addEventListener("abort", onAbort, { once: true });
200
+ if (dialogOptions?.timeout !== undefined) {
201
+ timeoutId = setTimeout(() => {
202
+ if (settled) return;
203
+ try {
204
+ dialogOptions.onTimeout?.();
205
+ } catch (error) {
206
+ // A throwing `onTimeout` must not leave the elicitation promise
207
+ // pending — settle it via `finish` below regardless.
208
+ logger.warn("ACP elicitation onTimeout threw", { sessionId, method, error });
209
+ }
210
+ finish(undefined);
211
+ }, dialogOptions.timeout);
212
+ // A long pending timeout alone shouldn't keep the event loop alive when
213
+ // the rest of the agent has shut down — matches `job-manager.ts` /
214
+ // `executor.ts` timer hygiene. Connection + session lifetimes keep the
215
+ // loop alive on the happy path.
216
+ timeoutId.unref();
217
+ }
218
+ connection
219
+ .unstable_createElicitation({
220
+ mode: "form",
221
+ sessionId,
222
+ message,
223
+ requestedSchema: {
224
+ type: "object",
225
+ properties: { value: property },
226
+ required: ["value"],
227
+ },
228
+ })
229
+ .then(finish, error => {
230
+ // Caller may already have moved on via abort/timeout; suppress noise.
231
+ if (settled) return;
232
+ logger.warn("ACP elicitation failed", { sessionId, method, error });
233
+ finish(undefined);
234
+ });
235
+ const response = await promise;
236
+ if (!response || response.action !== "accept" || !response.content) {
237
+ return undefined;
238
+ }
239
+ return response.content.value;
240
+ }
241
+
242
+ /**
243
+ * Build an {@link ExtensionUIContext} that translates skill/extension UI
244
+ * requests into ACP elicitations against `connection` for the session
245
+ * returned by `getSessionId()`. The id is read lazily at each elicitation
246
+ * because `AgentSession.sessionId` is a getter over `sessionManager` state
247
+ * that mutates when an extension command calls `ctx.newSession` /
248
+ * `ctx.switchSession` — snapshotting it once at factory time would route
249
+ * later elicitations to the pre-switch id. Live reads keep the bridge
250
+ * symmetric with every other `sessionUpdate` call in this file
251
+ * (`record.session.sessionId` is always evaluated at emit time).
252
+ *
253
+ * The non-elicitation surface (custom components, editor, theming,
254
+ * terminal input) remains stubbed — ACP clients render those themselves
255
+ * or not at all. Capability gating respects the client's `initialize`
256
+ * advertisement.
257
+ */
258
+ export function createAcpExtensionUiContext(
259
+ connection: AgentSideConnection,
260
+ getSessionId: () => string,
261
+ clientCapabilities: ClientCapabilities | undefined,
262
+ ): ExtensionUIContext {
263
+ const supportsForm = clientCapabilities?.elicitation?.form != null;
264
+ return {
265
+ select: async (title, options, dialogOptions) => {
266
+ if (!supportsForm) return undefined;
267
+ const value = await elicitFromAcpClient(
268
+ connection,
269
+ getSessionId(),
270
+ "select",
271
+ title,
272
+ { type: "string", enum: options },
273
+ dialogOptions,
274
+ );
275
+ return typeof value === "string" ? value : undefined;
276
+ },
277
+ confirm: async (title, message, dialogOptions) => {
278
+ if (!supportsForm) return false;
279
+ const value = await elicitFromAcpClient(
280
+ connection,
281
+ getSessionId(),
282
+ "confirm",
283
+ message.trim().length > 0 ? `${title}\n\n${message}` : title,
284
+ { type: "boolean" },
285
+ dialogOptions,
286
+ );
287
+ return typeof value === "boolean" ? value : false;
288
+ },
289
+ input: async (title, placeholder, dialogOptions) => {
290
+ if (!supportsForm) return undefined;
291
+ const value = await elicitFromAcpClient(
292
+ connection,
293
+ getSessionId(),
294
+ "input",
295
+ title,
296
+ // ACP's `StringPropertySchema` has no `placeholder` field, so we
297
+ // surface the placeholder text as `description` — the closest
298
+ // semantic field a client can render alongside the input.
299
+ // Empty / whitespace-only placeholders are treated as absent.
300
+ { type: "string", ...(placeholder?.trim() ? { description: placeholder } : {}) },
301
+ dialogOptions,
302
+ );
303
+ return typeof value === "string" ? value : undefined;
304
+ },
305
+ notify: (message, type) => {
306
+ logger.debug("ACP extension notification", { message, type });
307
+ },
308
+ onTerminalInput: () => () => {},
309
+ setStatus: () => {},
310
+ setWorkingMessage: () => {},
311
+ setWidget: () => {},
312
+ setFooter: () => {},
313
+ setHeader: () => {},
314
+ setTitle: () => {},
315
+ custom: async () => undefined as never,
316
+ pasteToEditor: () => {},
317
+ setEditorText: () => {},
318
+ getEditorText: () => "",
319
+ editor: async () => undefined,
320
+ setEditorComponent: () => {},
321
+ get theme() {
322
+ return theme;
323
+ },
324
+ getAllThemes: async () => [],
325
+ getTheme: async () => undefined,
326
+ setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
327
+ getToolsExpanded: () => false,
328
+ setToolsExpanded: () => {},
329
+ };
330
+ }
170
331
 
171
332
  export class AcpAgent implements Agent {
172
333
  #connection: AgentSideConnection;
@@ -380,31 +541,58 @@ export class AcpAgent implements Agent {
380
541
 
381
542
  async prompt(params: PromptRequest): Promise<PromptResponse> {
382
543
  const record = this.#getSessionRecord(params.sessionId);
383
- if (record.promptTurn && !record.promptTurn.settled) {
544
+ const activeTurn = record.promptTurn;
545
+ if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
384
546
  throw new Error("ACP prompt already in progress for this session");
385
547
  }
548
+ return await this.#queuePrompt(record, async () => {
549
+ const queuedTurn = record.promptTurn;
550
+ if (queuedTurn && !queuedTurn.settled) {
551
+ await queuedTurn.promise.catch(() => undefined);
552
+ }
386
553
 
387
- const converted = this.#convertPromptBlocks(params.prompt);
388
- const pendingPrompt = Promise.withResolvers<PromptResponse>();
389
- record.promptTurn = {
390
- userMessageId: params.messageId ?? crypto.randomUUID(),
391
- cancelRequested: false,
392
- settled: false,
393
- usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
394
- unsubscribe: undefined,
395
- resolve: pendingPrompt.resolve,
396
- reject: pendingPrompt.reject,
397
- };
554
+ const converted = this.#convertPromptBlocks(params.prompt);
555
+ const pendingPrompt = Promise.withResolvers<PromptResponse>();
556
+ record.promptTurn = {
557
+ userMessageId: params.messageId ?? crypto.randomUUID(),
558
+ cancelRequested: false,
559
+ settled: false,
560
+ usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
561
+ unsubscribe: undefined,
562
+ resolve: pendingPrompt.resolve,
563
+ reject: pendingPrompt.reject,
564
+ promise: pendingPrompt.promise,
565
+ };
398
566
 
399
- record.promptTurn.unsubscribe = record.session.subscribe(event => {
400
- void this.#handlePromptEvent(record, event);
401
- });
567
+ record.promptTurn.unsubscribe = record.session.subscribe(event => {
568
+ void this.#handlePromptEvent(record, event);
569
+ });
402
570
 
403
- this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
404
- this.#finishPrompt(record, undefined, error);
571
+ this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
572
+ this.#finishPrompt(record, undefined, error);
573
+ });
574
+
575
+ return await pendingPrompt.promise;
405
576
  });
577
+ }
406
578
 
407
- return await pendingPrompt.promise;
579
+ async #queuePrompt(record: ManagedSessionRecord, run: () => Promise<PromptResponse>): Promise<PromptResponse> {
580
+ const nextQueue = Promise.withResolvers<void>();
581
+ const releaseQueue = nextQueue.resolve;
582
+ const previousQueue = record.promptQueue;
583
+ record.promptQueue = {
584
+ promise: nextQueue.promise,
585
+ release: releaseQueue,
586
+ };
587
+ await previousQueue.promise;
588
+ try {
589
+ return await run();
590
+ } finally {
591
+ releaseQueue();
592
+ if (record.promptQueue.release === releaseQueue) {
593
+ record.promptQueue.release = undefined;
594
+ }
595
+ }
408
596
  }
409
597
 
410
598
  async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
@@ -700,6 +888,7 @@ export class AcpAgent implements Agent {
700
888
  session,
701
889
  mcpManager: undefined,
702
890
  promptTurn: undefined,
891
+ promptQueue: { promise: Promise.resolve(), release: undefined },
703
892
  liveMessageId: undefined,
704
893
  liveMessageProgress: undefined,
705
894
  extensionsConfigured: false,
@@ -777,6 +966,7 @@ export class AcpAgent implements Agent {
777
966
 
778
967
  if (event.type === "agent_end") {
779
968
  await this.#emitEndOfTurnUpdates(record);
969
+ await record.session.waitForIdle();
780
970
  this.#finishPrompt(record, {
781
971
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
782
972
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -1552,7 +1742,7 @@ export class AcpAgent implements Agent {
1552
1742
  getActiveTools: () => record.session.getActiveToolNames(),
1553
1743
  getAllTools: () => record.session.getAllToolNames(),
1554
1744
  setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
1555
- getCommands: () => [],
1745
+ getCommands: () => getSessionSlashCommands(record.session),
1556
1746
  setModel: async model => {
1557
1747
  const apiKey = await record.session.modelRegistry.getApiKey(model);
1558
1748
  if (!apiKey) {
@@ -1607,7 +1797,15 @@ export class AcpAgent implements Agent {
1607
1797
  },
1608
1798
  compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
1609
1799
  },
1610
- acpExtensionUiContext,
1800
+ // Per-session getter: `record.session.sessionId` reads through to
1801
+ // `sessionManager.getSessionId()` (it's a getter, not a field), so an
1802
+ // extension command that calls `ctx.newSession` / `ctx.switchSession`
1803
+ // — both exposed in the block just above — mutates the underlying id
1804
+ // mid-flight. Reading lazily on each elicitation matches every other
1805
+ // `sessionUpdate` call in this file. Hoisting the factory to an
1806
+ // `AcpAgent` field would still be wrong because it would also lose
1807
+ // the per-`record` binding.
1808
+ createAcpExtensionUiContext(this.#connection, () => record.session.sessionId, this.#clientCapabilities),
1611
1809
  );
1612
1810
  await extensionRunner.emit({ type: "session_start" });
1613
1811
  record.extensionsConfigured = true;
@@ -2,7 +2,7 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import { TERMINAL } from "@oh-my-pi/pi-tui";
5
- import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
5
+ import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
6
  import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
@@ -32,6 +32,33 @@ function normalizePremiumRequests(value: number): number {
32
32
  return Math.round((value + Number.EPSILON) * 100) / 100;
33
33
  }
34
34
 
35
+ const SCRATCH_ROOTS: readonly string[] = (() => {
36
+ const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
37
+ if (process.platform === "win32") {
38
+ const { TEMP, TMP, SystemRoot } = process.env;
39
+ if (TEMP) roots.add(TEMP);
40
+ if (TMP) roots.add(TMP);
41
+ if (SystemRoot) roots.add(path.join(SystemRoot, "Temp"));
42
+ } else {
43
+ roots.add("/tmp");
44
+ roots.add("/var/tmp");
45
+ if (process.platform === "darwin") {
46
+ roots.add("/private/tmp");
47
+ roots.add("/private/var/tmp");
48
+ }
49
+ }
50
+ return [...roots];
51
+ })();
52
+
53
+ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
54
+ for (const root of SCRATCH_ROOTS) {
55
+ if (pathIsWithin(root, pwd)) {
56
+ return { scratch: true, relative: relativePathWithinRoot(root, pwd) };
57
+ }
58
+ }
59
+ return { scratch: false, relative: null };
60
+ }
61
+
35
62
  // ═══════════════════════════════════════════════════════════════════════════
36
63
  // Segment Implementations
37
64
  // ═══════════════════════════════════════════════════════════════════════════
@@ -150,10 +177,16 @@ const pathSegment: StatusLineSegment = {
150
177
  render(ctx) {
151
178
  const opts = ctx.options.path ?? {};
152
179
 
153
- let pwd = getProjectDir();
180
+ const projectDir = getProjectDir();
181
+ const { scratch, relative } = classifyProjectDir(projectDir);
182
+ let pwd = projectDir;
154
183
 
155
184
  if (opts.stripWorkPrefix !== false) {
156
- pwd = stripDisplayRoot(pwd);
185
+ if (scratch) {
186
+ if (relative) pwd = relative;
187
+ } else {
188
+ pwd = stripDisplayRoot(pwd);
189
+ }
157
190
  }
158
191
  if (opts.abbreviate !== false) {
159
192
  pwd = shortenPath(pwd);
@@ -166,7 +199,8 @@ const pathSegment: StatusLineSegment = {
166
199
  pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
167
200
  }
168
201
 
169
- const content = withIcon(theme.icon.folder, pwd);
202
+ const icon = scratch ? theme.icon.scratchFolder : theme.icon.folder;
203
+ const content = withIcon(icon, pwd);
170
204
  return { content: theme.fg("statusLinePath", content), visible: true };
171
205
  },
172
206
  };
@@ -16,6 +16,7 @@ import type {
16
16
  SendUserMessageHandler,
17
17
  TerminalInputHandler,
18
18
  } from "../../extensibility/extensions";
19
+ import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
19
20
  import { HookEditorComponent } from "../../modes/components/hook-editor";
20
21
  import { HookInputComponent } from "../../modes/components/hook-input";
21
22
  import { HookSelectorComponent } from "../../modes/components/hook-selector";
@@ -109,7 +110,7 @@ export class ExtensionUiController {
109
110
  },
110
111
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
111
112
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
112
- getCommands: () => [],
113
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
113
114
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
114
115
  setSessionName: name => this.#updateSessionName(name),
115
116
  };
@@ -349,7 +350,7 @@ export class ExtensionUiController {
349
350
  },
350
351
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
351
352
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
352
- getCommands: () => [],
353
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
353
354
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
354
355
  setSessionName: name => this.#updateSessionName(name),
355
356
  };