@oh-my-pi/pi-coding-agent 15.11.7 → 15.11.8

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 (61) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/cli.js +363 -356
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +12 -0
  5. package/dist/types/collab/guest.d.ts +21 -0
  6. package/dist/types/collab/host.d.ts +13 -0
  7. package/dist/types/collab/protocol.d.ts +100 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +21 -1
  11. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  12. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  13. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  15. package/dist/types/modes/components/segment-track.d.ts +11 -6
  16. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  17. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  18. package/dist/types/modes/interactive-mode.d.ts +7 -0
  19. package/dist/types/modes/types.d.ts +8 -0
  20. package/dist/types/session/agent-session.d.ts +11 -0
  21. package/dist/types/session/session-manager.d.ts +21 -0
  22. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  23. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  24. package/package.json +14 -12
  25. package/scripts/bench-guard.ts +71 -0
  26. package/src/cli/args.ts +2 -0
  27. package/src/cli-commands.ts +1 -0
  28. package/src/collab/crypto.ts +57 -0
  29. package/src/collab/guest.ts +421 -0
  30. package/src/collab/host.ts +494 -0
  31. package/src/collab/protocol.ts +191 -0
  32. package/src/collab/relay-client.ts +216 -0
  33. package/src/commands/join.ts +39 -0
  34. package/src/config/model-registry.ts +22 -14
  35. package/src/config/settings-schema.ts +27 -1
  36. package/src/extensibility/slash-commands.ts +1 -97
  37. package/src/internal-urls/docs-index.generated.ts +3 -2
  38. package/src/main.ts +11 -2
  39. package/src/modes/components/agent-hub.ts +119 -22
  40. package/src/modes/components/assistant-message.ts +126 -6
  41. package/src/modes/components/collab-prompt-message.ts +30 -0
  42. package/src/modes/components/hook-selector.ts +4 -5
  43. package/src/modes/components/segment-track.ts +44 -7
  44. package/src/modes/components/status-line/component.ts +21 -1
  45. package/src/modes/components/status-line/presets.ts +1 -1
  46. package/src/modes/components/status-line/segments.ts +13 -0
  47. package/src/modes/components/status-line/types.ts +10 -0
  48. package/src/modes/components/tips.txt +2 -1
  49. package/src/modes/controllers/input-controller.ts +72 -6
  50. package/src/modes/controllers/selector-controller.ts +2 -0
  51. package/src/modes/controllers/streaming-reveal.ts +7 -0
  52. package/src/modes/interactive-mode.ts +12 -4
  53. package/src/modes/types.ts +8 -0
  54. package/src/modes/utils/ui-helpers.ts +7 -0
  55. package/src/sdk.ts +239 -36
  56. package/src/session/agent-session.ts +17 -0
  57. package/src/session/session-manager.ts +44 -0
  58. package/src/session/snapcompact-inline.ts +9 -3
  59. package/src/slash-commands/builtin-registry.ts +210 -0
  60. package/src/tools/read.ts +38 -5
  61. package/src/tools/write.ts +13 -42
@@ -493,6 +493,18 @@ const sessionNameSegment: StatusLineSegment = {
493
493
  },
494
494
  };
495
495
 
496
+ const collabSegment: StatusLineSegment = {
497
+ id: "collab",
498
+ render(ctx) {
499
+ if (!ctx.collab) return { content: "", visible: false };
500
+ const label =
501
+ ctx.collab.role === "host"
502
+ ? `⇄ collab:${ctx.collab.participantCount}`
503
+ : `⇄ collab guest:${ctx.collab.participantCount}`;
504
+ return { content: theme.fg("accent", label), visible: true };
505
+ },
506
+ };
507
+
496
508
  function pickUsageColor(percent: number): "muted" | "warning" | "error" {
497
509
  if (percent >= 80) return "error";
498
510
  if (percent >= 50) return "warning";
@@ -573,6 +585,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
573
585
  cache_hit: cacheHitSegment,
574
586
  session_name: sessionNameSegment,
575
587
  usage: usageSegment,
588
+ collab: collabSegment,
576
589
  };
577
590
 
578
591
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -1,8 +1,17 @@
1
+ import type { CollabSessionState } from "../../../collab/protocol";
1
2
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
2
3
  import type { AgentSession } from "../../../session/agent-session";
3
4
 
4
5
  export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
5
6
 
7
+ /** Collab session indicator + (guest-only) host-state override for segments. */
8
+ export interface CollabStatus {
9
+ role: "host" | "guest";
10
+ participantCount: number;
11
+ /** Guest only: host footer snapshot that overrides locally computed values. */
12
+ stateOverride?: CollabSessionState | null;
13
+ }
14
+
6
15
  export interface StatusLineSegmentOptions {
7
16
  model?: { showThinkingLevel?: boolean };
8
17
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
@@ -49,6 +58,7 @@ export interface SegmentContext {
49
58
  enabled: boolean;
50
59
  paused: boolean;
51
60
  } | null;
61
+ collab: CollabStatus | null;
52
62
  // Cached values for performance (computed once per render)
53
63
  usageStats: {
54
64
  input: number;
@@ -16,4 +16,5 @@ Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smo
16
16
  Press ctrl+r to search your prompt history and reuse a past message
17
17
  `/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
18
18
  `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
19
- `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
19
+ `/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
20
+ Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
@@ -1,8 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
- import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
5
- import { getRoleInfo } from "../../config/model-roles";
4
+ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
5
  import { isSettingsInitialized, settings } from "../../config/settings";
7
6
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
7
  import { renderSegmentTrack } from "../../modes/components/segment-track";
@@ -18,6 +17,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
18
17
  import { isLowSignalTitleInput } from "../../tiny/text";
19
18
  import { tinyTitleClient } from "../../tiny/title-client";
20
19
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
20
+ import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
21
21
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
22
22
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
23
23
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
@@ -125,6 +125,16 @@ export class InputController {
125
125
  if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
126
126
  return;
127
127
  }
128
+ if (this.ctx.collabGuest) {
129
+ // Guest Esc: ask the host to interrupt its agent; the local replica
130
+ // session is never streaming, so the native abort path below would
131
+ // no-op.
132
+ if (this.ctx.collabGuest.state?.isStreaming || this.ctx.loadingAnimation) {
133
+ this.ctx.notifyInterrupting();
134
+ this.ctx.collabGuest.sendAbort();
135
+ }
136
+ return;
137
+ }
128
138
  if (this.ctx.loadingAnimation) {
129
139
  if (this.ctx.cancelPendingSubmission()) {
130
140
  return;
@@ -392,6 +402,32 @@ export class InputController {
392
402
  text = slashResult;
393
403
  }
394
404
 
405
+ // Collab guest: prompts execute on the host; local slash/skill/bash/
406
+ // python execution is host-only (builtins are gated inside
407
+ // executeBuiltinSlashCommand, which already consumed allowed ones).
408
+ if (this.ctx.collabGuest) {
409
+ if (text.startsWith("/")) {
410
+ this.ctx.showStatus(`${text.split(/\s+/, 1)[0]} is host-only during a collab session`);
411
+ this.ctx.editor.setText("");
412
+ return;
413
+ }
414
+ if (text.startsWith("!") || text.startsWith("$")) {
415
+ this.ctx.showStatus("Local execution is host-only during a collab session");
416
+ this.ctx.editor.setText("");
417
+ return;
418
+ }
419
+ this.ctx.editor.addToHistory(text);
420
+ this.ctx.editor.setText("");
421
+ this.ctx.editor.imageLinks = undefined;
422
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
423
+ this.ctx.pendingImages = [];
424
+ this.ctx.pendingImageLinks = [];
425
+ // No local render: the prompt comes back from the host as a
426
+ // collab-prompt event/entry and renders with the author badge.
427
+ this.ctx.collabGuest.sendPrompt(text, images);
428
+ return;
429
+ }
430
+
395
431
  // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
396
432
  // free-text Enter semantics applied a few lines below at the streaming
397
433
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
@@ -874,11 +910,41 @@ export class InputController {
874
910
  `Unsupported pasted image format: ${image.mimeType}`,
875
911
  );
876
912
  } catch (error) {
913
+ if (error instanceof ImageInputTooLargeError) {
914
+ this.ctx.editor.pasteText(path);
915
+ this.ctx.ui.requestRender();
916
+ this.ctx.showStatus(error.message);
917
+ return;
918
+ }
919
+ if (isEnoent(error)) {
920
+ // #2375: the bracketed paste forwarded by a local terminal carries a
921
+ // path on the *local* filesystem. When omp itself runs over SSH, that
922
+ // path is unreachable here; pasting it as text would look like the
923
+ // image was attached when in fact nothing was sent. Refuse the silent
924
+ // degrade and tell the user how to send the bytes for real. The
925
+ // pasted path is untrusted terminal input — strip control/ANSI/
926
+ // newlines, collapse home to `~`, and bound the displayed length
927
+ // before splicing it into the status string.
928
+ const displayPath = truncateToWidth(
929
+ shortenPath(
930
+ sanitizeText(path)
931
+ .replace(/[\r\n\t]+/g, " ")
932
+ .trim(),
933
+ ),
934
+ TRUNCATE_LENGTHS.CONTENT,
935
+ );
936
+ const env = process.env;
937
+ const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
938
+ this.ctx.showStatus(
939
+ overSsh
940
+ ? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.`
941
+ : `Image not found at ${displayPath}`,
942
+ );
943
+ return;
944
+ }
877
945
  this.ctx.editor.pasteText(path);
878
946
  this.ctx.ui.requestRender();
879
- this.ctx.showStatus(
880
- error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
881
- );
947
+ this.ctx.showStatus("Failed to read pasted image path");
882
948
  }
883
949
  }
884
950
 
@@ -1007,7 +1073,7 @@ export class InputController {
1007
1073
  // the cycle status is just a status-line-style chip track (active role
1008
1074
  // filled), matching the plan-approval model slider.
1009
1075
  const track = renderSegmentTrack(
1010
- cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
1076
+ cycleOrder.map(role => ({ label: role })),
1011
1077
  cycleOrder.indexOf(result.role),
1012
1078
  );
1013
1079
  this.ctx.showStatus(track, { dim: false });
@@ -1188,6 +1188,8 @@ export class SelectorController {
1188
1188
  hubKeys,
1189
1189
  onDone: done,
1190
1190
  requestRender: () => this.ctx.ui.requestRender(),
1191
+ registry: this.ctx.collabGuest?.agentRegistry,
1192
+ remote: this.ctx.collabGuest?.hubRemote,
1191
1193
  });
1192
1194
 
1193
1195
  overlayHandle = this.ctx.ui.showOverlay(hub, {
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { getSegmenter } from "@oh-my-pi/pi-tui";
3
+ import { LRUCache } from "lru-cache/raw";
3
4
  import type { AssistantMessageComponent } from "../components/assistant-message";
4
5
 
5
6
  export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
@@ -15,11 +16,17 @@ type StreamingRevealControllerOptions = {
15
16
  requestRender(): void;
16
17
  };
17
18
 
19
+ const graphemeCountCache = new LRUCache<string, number>({ max: 128 });
20
+
18
21
  function countGraphemes(text: string): number {
22
+ if (text.length === 0) return 0;
23
+ const cached = graphemeCountCache.get(text);
24
+ if (cached !== undefined) return cached;
19
25
  let count = 0;
20
26
  for (const _segment of getSegmenter().segment(text)) {
21
27
  count += 1;
22
28
  }
29
+ graphemeCountCache.set(text, count);
23
30
  return count;
24
31
  }
25
32
 
@@ -49,8 +49,9 @@ import {
49
49
  } from "@oh-my-pi/pi-utils";
50
50
  import chalk from "chalk";
51
51
  import { reset as resetCapabilities } from "../capability";
52
+ import type { CollabGuestLink } from "../collab/guest";
53
+ import type { CollabHost } from "../collab/host";
52
54
  import { KeybindingsManager } from "../config/keybindings";
53
- import { MODEL_ROLES, type ModelRole } from "../config/model-roles";
54
55
  import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
55
56
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
56
57
  import type {
@@ -62,7 +63,7 @@ import type {
62
63
  ExtensionWidgetOptions,
63
64
  } from "../extensibility/extensions";
64
65
  import type { CompactOptions } from "../extensibility/extensions/types";
65
- import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
66
+ import { loadSlashCommands } from "../extensibility/slash-commands";
66
67
  import type { Goal, GoalModeState } from "../goals/state";
67
68
  import { resolveLocalUrlToPath } from "../internal-urls";
68
69
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
@@ -82,7 +83,7 @@ import { HistoryStorage } from "../session/history-storage";
82
83
  import type { SessionContext, SessionManager } from "../session/session-manager";
83
84
  import { getRecentSessions } from "../session/session-manager";
84
85
  import type { ShakeMode } from "../session/shake-types";
85
- import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES } from "../slash-commands/builtin-registry";
86
+ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, BUILTIN_SLASH_COMMANDS } from "../slash-commands/builtin-registry";
86
87
  import { formatDuration } from "../slash-commands/helpers/format";
87
88
  import { STTController, type SttState } from "../stt";
88
89
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
@@ -397,6 +398,8 @@ export class InteractiveMode implements InteractiveModeContext {
397
398
  fileSlashCommands: Set<string> = new Set();
398
399
  skillCommands: Map<string, string> = new Map();
399
400
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
401
+ collabHost?: CollabHost;
402
+ collabGuest?: CollabGuestLink;
400
403
 
401
404
  #pendingSlashCommands: SlashCommand[] = [];
402
405
  #cleanupUnsubscribe?: () => void;
@@ -423,6 +426,12 @@ export class InteractiveMode implements InteractiveModeContext {
423
426
  readonly #commandController: CommandController;
424
427
  readonly #todoCommandController: TodoCommandController;
425
428
  readonly #eventController: EventController;
429
+ get eventController(): EventController {
430
+ return this.#eventController;
431
+ }
432
+ get eventBus(): EventBus | undefined {
433
+ return this.#eventBus;
434
+ }
426
435
  readonly #extensionUiController: ExtensionUiController;
427
436
  readonly #inputController: InputController;
428
437
  readonly #selectorController: SelectorController;
@@ -2491,7 +2500,6 @@ export class InteractiveMode implements InteractiveModeContext {
2491
2500
  index: startTierIndex,
2492
2501
  segments: cycle.models.map(entry => ({
2493
2502
  label: entry.role,
2494
- color: MODEL_ROLES[entry.role as ModelRole]?.color,
2495
2503
  detail: entry.model.name || entry.model.id,
2496
2504
  })),
2497
2505
  onChange: index => {
@@ -2,6 +2,8 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
4
4
  import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
5
+ import type { CollabGuestLink } from "../collab/guest";
6
+ import type { CollabHost } from "../collab/host";
5
7
  import type { KeybindingsManager } from "../config/keybindings";
6
8
  import type { Settings } from "../config/settings";
7
9
  import type {
@@ -19,6 +21,7 @@ import type { HistoryStorage } from "../session/history-storage";
19
21
  import type { SessionContext, SessionManager } from "../session/session-manager";
20
22
  import type { ShakeMode } from "../session/shake-types";
21
23
  import type { LspStartupServerInfo } from "../tools";
24
+ import type { EventBus } from "../utils/event-bus";
22
25
  import type { AssistantMessageComponent } from "./components/assistant-message";
23
26
  import type { BashExecutionComponent } from "./components/bash-execution";
24
27
  import type { CustomEditor } from "./components/custom-editor";
@@ -29,6 +32,7 @@ import type { HookSelectorComponent, HookSelectorOptions } from "./components/ho
29
32
  import type { StatusLineComponent } from "./components/status-line";
30
33
  import type { ToolExecutionHandle } from "./components/tool-execution";
31
34
  import type { TranscriptContainer } from "./components/transcript-container";
35
+ import type { EventController } from "./controllers/event-controller";
32
36
  import type { LoopLimitRuntime } from "./loop-limit";
33
37
  import type { OAuthManualInputManager } from "./oauth-manual-input";
34
38
  import type { Theme } from "./theme/theme";
@@ -101,6 +105,10 @@ export interface InteractiveModeContext {
101
105
  mcpManager?: MCPManager;
102
106
  lspServers?: LspStartupServerInfo[];
103
107
  titleSystemPrompt?: string;
108
+ collabHost?: CollabHost;
109
+ collabGuest?: CollabGuestLink;
110
+ eventController: EventController;
111
+ eventBus?: EventBus;
104
112
 
105
113
  // State
106
114
  isInitialized: boolean;
@@ -1,11 +1,13 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
4
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
4
5
  import { settings } from "../../config/settings";
5
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
6
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
8
9
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
10
+ import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
9
11
  import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
10
12
  import { CustomMessageComponent } from "../../modes/components/custom-message";
11
13
  import { DynamicBorder } from "../../modes/components/dynamic-border";
@@ -185,6 +187,11 @@ export class UiHelpers {
185
187
  this.ctx.chatContainer.addChild(component);
186
188
  break;
187
189
  }
190
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
191
+ const component = new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>);
192
+ this.ctx.chatContainer.addChild(component);
193
+ break;
194
+ }
188
195
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
189
196
  const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
190
197
  component.setExpanded(this.ctx.toolOutputExpanded);