@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.0

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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +1 -7
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
@@ -32,6 +32,8 @@ import type {
32
32
  MessageRenderer,
33
33
  RegisteredCommand,
34
34
  RegisteredTool,
35
+ ResourcesDiscoverEvent,
36
+ ResourcesDiscoverResult,
35
37
  SessionBeforeCompactResult,
36
38
  SessionBeforeTreeResult,
37
39
  SessionCompactingResult,
@@ -64,6 +66,8 @@ export type NavigateTreeHandler = (
64
66
  options?: { summarize?: boolean },
65
67
  ) => Promise<{ cancelled: boolean }>;
66
68
 
69
+ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>;
70
+
67
71
  export type ShutdownHandler = () => void;
68
72
 
69
73
  /**
@@ -102,6 +106,8 @@ const noOpUIContext: ExtensionUIContext = {
102
106
  getAllThemes: () => Promise.resolve([]),
103
107
  getTheme: () => Promise.resolve(undefined),
104
108
  setTheme: (_theme: string | Theme) => Promise.resolve({ success: false, error: "UI not available" }),
109
+ getToolsExpanded: () => false,
110
+ setToolsExpanded: () => {},
105
111
  };
106
112
 
107
113
  export class ExtensionRunner {
@@ -114,10 +120,13 @@ export class ExtensionRunner {
114
120
  private hasPendingMessagesFn: () => boolean = () => false;
115
121
  private getContextUsageFn: () => ContextUsage | undefined = () => undefined;
116
122
  private compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
123
+ private getSystemPromptFn: () => string = () => "";
117
124
  private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
118
125
  private branchHandler: BranchHandler = async () => ({ cancelled: false });
119
126
  private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
127
+ private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false });
120
128
  private shutdownHandler: ShutdownHandler = () => {};
129
+ private commandDiagnostics: Array<{ type: string; message: string; path: string }> = [];
121
130
 
122
131
  constructor(
123
132
  private readonly extensions: Extension[],
@@ -142,6 +151,7 @@ export class ExtensionRunner {
142
151
  this.runtime.getActiveTools = actions.getActiveTools;
143
152
  this.runtime.getAllTools = actions.getAllTools;
144
153
  this.runtime.setActiveTools = actions.setActiveTools;
154
+ this.runtime.getCommands = actions.getCommands;
145
155
  this.runtime.setModel = actions.setModel;
146
156
  this.runtime.getThinkingLevel = actions.getThinkingLevel;
147
157
  this.runtime.setThinkingLevel = actions.setThinkingLevel;
@@ -152,6 +162,7 @@ export class ExtensionRunner {
152
162
  this.abortFn = contextActions.abort;
153
163
  this.hasPendingMessagesFn = contextActions.hasPendingMessages;
154
164
  this.shutdownHandler = contextActions.shutdown;
165
+ this.getSystemPromptFn = contextActions.getSystemPrompt;
155
166
 
156
167
  // Command context actions (optional, only for interactive mode)
157
168
  if (commandContextActions) {
@@ -159,6 +170,7 @@ export class ExtensionRunner {
159
170
  this.newSessionHandler = commandContextActions.newSession;
160
171
  this.branchHandler = commandContextActions.branch;
161
172
  this.navigateTreeHandler = commandContextActions.navigateTree;
173
+ this.switchSessionHandler = commandContextActions.switchSession;
162
174
  this.getContextUsageFn = commandContextActions.getContextUsage;
163
175
  this.compactFn = commandContextActions.compact;
164
176
  }
@@ -279,16 +291,31 @@ export class ExtensionRunner {
279
291
  return undefined;
280
292
  }
281
293
 
282
- getRegisteredCommands(): RegisteredCommand[] {
294
+ getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
295
+ this.commandDiagnostics = [];
296
+
283
297
  const commands: RegisteredCommand[] = [];
284
298
  for (const ext of this.extensions) {
285
299
  for (const command of ext.commands.values()) {
300
+ if (reserved?.has(command.name)) {
301
+ const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`;
302
+ this.commandDiagnostics.push({ type: "warning", message, path: ext.path });
303
+ if (!this.hasUI()) {
304
+ logger.warn(message);
305
+ }
306
+ continue;
307
+ }
308
+
286
309
  commands.push(command);
287
310
  }
288
311
  }
289
312
  return commands;
290
313
  }
291
314
 
315
+ getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> {
316
+ return this.commandDiagnostics;
317
+ }
318
+
292
319
  getCommand(name: string): RegisteredCommand | undefined {
293
320
  for (const ext of this.extensions) {
294
321
  const command = ext.commands.get(name);
@@ -316,6 +343,7 @@ export class ExtensionRunner {
316
343
  abort: () => this.abortFn(),
317
344
  hasPendingMessages: () => this.hasPendingMessagesFn(),
318
345
  shutdown: () => this.shutdownHandler(),
346
+ getSystemPrompt: () => this.getSystemPromptFn(),
319
347
  hasQueuedMessages: () => this.hasPendingMessagesFn(), // deprecated alias
320
348
  };
321
349
  }
@@ -335,6 +363,7 @@ export class ExtensionRunner {
335
363
  newSession: options => this.newSessionHandler(options),
336
364
  branch: entryId => this.branchHandler(entryId),
337
365
  navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
366
+ switchSession: sessionPath => this.switchSessionHandler(sessionPath),
338
367
  compact: instructionsOrOptions => this.compactFn(instructionsOrOptions),
339
368
  };
340
369
  }
@@ -493,6 +522,54 @@ export class ExtensionRunner {
493
522
  return undefined;
494
523
  }
495
524
 
525
+ async emitResourcesDiscover(
526
+ cwd: string,
527
+ reason: ResourcesDiscoverEvent["reason"],
528
+ ): Promise<{
529
+ skillPaths: Array<{ path: string; extensionPath: string }>;
530
+ promptPaths: Array<{ path: string; extensionPath: string }>;
531
+ themePaths: Array<{ path: string; extensionPath: string }>;
532
+ }> {
533
+ const ctx = this.createContext();
534
+ const skillPaths: Array<{ path: string; extensionPath: string }> = [];
535
+ const promptPaths: Array<{ path: string; extensionPath: string }> = [];
536
+ const themePaths: Array<{ path: string; extensionPath: string }> = [];
537
+
538
+ for (const ext of this.extensions) {
539
+ const handlers = ext.handlers.get("resources_discover");
540
+ if (!handlers || handlers.length === 0) continue;
541
+
542
+ for (const handler of handlers) {
543
+ try {
544
+ const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason };
545
+ const handlerResult = await handler(event, ctx);
546
+ const result = handlerResult as ResourcesDiscoverResult | undefined;
547
+
548
+ if (result?.skillPaths?.length) {
549
+ skillPaths.push(...result.skillPaths.map(path => ({ path, extensionPath: ext.path })));
550
+ }
551
+ if (result?.promptPaths?.length) {
552
+ promptPaths.push(...result.promptPaths.map(path => ({ path, extensionPath: ext.path })));
553
+ }
554
+ if (result?.themePaths?.length) {
555
+ themePaths.push(...result.themePaths.map(path => ({ path, extensionPath: ext.path })));
556
+ }
557
+ } catch (err) {
558
+ const message = err instanceof Error ? err.message : String(err);
559
+ const stack = err instanceof Error ? err.stack : undefined;
560
+ this.emitError({
561
+ extensionPath: ext.path,
562
+ event: "resources_discover",
563
+ error: message,
564
+ stack,
565
+ });
566
+ }
567
+ }
568
+ }
569
+
570
+ return { skillPaths, promptPaths, themePaths };
571
+ }
572
+
496
573
  /** Emit input event. Transforms chain, "handled" short-circuits. */
497
574
  async emitInput(
498
575
  text: string,
@@ -28,8 +28,19 @@ import type {
28
28
  SessionEntry,
29
29
  SessionManager,
30
30
  } from "../../session/session-manager";
31
- import type { BashToolDetails, FindToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools";
31
+ import type {
32
+ BashToolDetails,
33
+ BashToolInput,
34
+ FindToolDetails,
35
+ FindToolInput,
36
+ GrepToolDetails,
37
+ GrepToolInput,
38
+ ReadToolDetails,
39
+ ReadToolInput,
40
+ WriteToolInput,
41
+ } from "../../tools";
32
42
  import type { EventBus } from "../../utils/event-bus";
43
+ import type { SlashCommandInfo } from "../slash-commands";
33
44
 
34
45
  export type { AppAction, KeybindingsManager } from "../../config/keybindings";
35
46
  export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -123,6 +134,12 @@ export interface ExtensionUIContext {
123
134
 
124
135
  /** Set the current theme by name or Theme object. */
125
136
  setTheme(theme: string | Theme): Promise<{ success: boolean; error?: string }>;
137
+
138
+ /** Get current tool output expansion state. */
139
+ getToolsExpanded(): boolean;
140
+
141
+ /** Set tool output expansion state. */
142
+ setToolsExpanded(expanded: boolean): void;
126
143
  }
127
144
 
128
145
  // ============================================================================
@@ -171,6 +188,8 @@ export interface ExtensionContext {
171
188
  hasPendingMessages(): boolean;
172
189
  /** Gracefully shutdown and exit. */
173
190
  shutdown(): void;
191
+ /** Get the current effective system prompt. */
192
+ getSystemPrompt(): string;
174
193
  /** @deprecated Use hasPendingMessages() instead */
175
194
  hasQueuedMessages(): boolean;
176
195
  }
@@ -198,6 +217,9 @@ export interface ExtensionCommandContext extends ExtensionContext {
198
217
  /** Navigate to a different point in the session tree. */
199
218
  navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
200
219
 
220
+ /** Switch to a different session file. */
221
+ switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
222
+
201
223
  /** Compact the session context (interactive mode shows UI). */
202
224
  compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
203
225
  }
@@ -243,9 +265,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
243
265
  execute(
244
266
  toolCallId: string,
245
267
  params: Static<TParams>,
268
+ signal: AbortSignal | undefined,
246
269
  onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
247
270
  ctx: ExtensionContext,
248
- signal?: AbortSignal,
249
271
  ): Promise<AgentToolResult<TDetails>>;
250
272
 
251
273
  /** Called on session lifecycle events - use to reconstruct state or cleanup resources */
@@ -263,6 +285,24 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
263
285
  ) => Component;
264
286
  }
265
287
 
288
+ // ============================================================================
289
+ // Resource Events
290
+ // ============================================================================
291
+
292
+ /** Fired after session_start to allow extensions to provide additional resource paths. */
293
+ export interface ResourcesDiscoverEvent {
294
+ type: "resources_discover";
295
+ cwd: string;
296
+ reason: "startup" | "reload";
297
+ }
298
+
299
+ /** Result from resources_discover event handler */
300
+ export interface ResourcesDiscoverResult {
301
+ skillPaths?: string[];
302
+ promptPaths?: string[];
303
+ themePaths?: string[];
304
+ }
305
+
266
306
  // ============================================================================
267
307
  // Session Events
268
308
  // ============================================================================
@@ -454,14 +494,56 @@ export interface InputEvent {
454
494
  // Tool Events
455
495
  // ============================================================================
456
496
 
457
- /** Fired before a tool executes. Can block. */
458
- export interface ToolCallEvent {
497
+ interface ToolCallEventBase {
459
498
  type: "tool_call";
460
- toolName: string;
461
499
  toolCallId: string;
500
+ }
501
+
502
+ export interface BashToolCallEvent extends ToolCallEventBase {
503
+ toolName: "bash";
504
+ input: BashToolInput;
505
+ }
506
+
507
+ export interface ReadToolCallEvent extends ToolCallEventBase {
508
+ toolName: "read";
509
+ input: ReadToolInput;
510
+ }
511
+
512
+ export interface EditToolCallEvent extends ToolCallEventBase {
513
+ toolName: "edit";
462
514
  input: Record<string, unknown>;
463
515
  }
464
516
 
517
+ export interface WriteToolCallEvent extends ToolCallEventBase {
518
+ toolName: "write";
519
+ input: WriteToolInput;
520
+ }
521
+
522
+ export interface GrepToolCallEvent extends ToolCallEventBase {
523
+ toolName: "grep";
524
+ input: GrepToolInput;
525
+ }
526
+
527
+ export interface FindToolCallEvent extends ToolCallEventBase {
528
+ toolName: "find";
529
+ input: FindToolInput;
530
+ }
531
+
532
+ export interface CustomToolCallEvent extends ToolCallEventBase {
533
+ toolName: string;
534
+ input: Record<string, unknown>;
535
+ }
536
+
537
+ /** Fired before a tool executes. Can block. */
538
+ export type ToolCallEvent =
539
+ | BashToolCallEvent
540
+ | ReadToolCallEvent
541
+ | EditToolCallEvent
542
+ | WriteToolCallEvent
543
+ | GrepToolCallEvent
544
+ | FindToolCallEvent
545
+ | CustomToolCallEvent;
546
+
465
547
  interface ToolResultEventBase {
466
548
  type: "tool_result";
467
549
  toolCallId: string;
@@ -515,8 +597,43 @@ export type ToolResultEvent =
515
597
  | FindToolResultEvent
516
598
  | CustomToolResultEvent;
517
599
 
600
+ /**
601
+ * Type guard for narrowing ToolCallEvent by tool name.
602
+ *
603
+ * Built-in tools narrow automatically (no type params needed):
604
+ * ```ts
605
+ * if (isToolCallEventType("bash", event)) {
606
+ * event.input.command; // string
607
+ * }
608
+ * ```
609
+ *
610
+ * Custom tools require explicit type parameters:
611
+ * ```ts
612
+ * if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
613
+ * event.input.action; // typed
614
+ * }
615
+ * ```
616
+ *
617
+ * Note: Direct narrowing via `event.toolName === "bash"` doesn't work because
618
+ * CustomToolCallEvent.toolName is `string` which overlaps with all literals.
619
+ */
620
+ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent;
621
+ export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent;
622
+ export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent;
623
+ export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent;
624
+ export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent;
625
+ export function isToolCallEventType(toolName: "find", event: ToolCallEvent): event is FindToolCallEvent;
626
+ export function isToolCallEventType<TName extends string, TInput extends Record<string, unknown>>(
627
+ toolName: TName,
628
+ event: ToolCallEvent,
629
+ ): event is ToolCallEvent & { toolName: TName; input: TInput };
630
+ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean {
631
+ return event.toolName === toolName;
632
+ }
633
+
518
634
  /** Union of all event types */
519
635
  export type ExtensionEvent =
636
+ | ResourcesDiscoverEvent
520
637
  | SessionEvent
521
638
  | ContextEvent
522
639
  | BeforeAgentStartEvent
@@ -659,6 +776,7 @@ export interface ExtensionAPI {
659
776
  // Event Subscription
660
777
  // =========================================================================
661
778
 
779
+ on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
662
780
  on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
663
781
  on(
664
782
  event: "session_before_switch",
@@ -775,6 +893,9 @@ export interface ExtensionAPI {
775
893
  /** Set the active tools by name. */
776
894
  setActiveTools(toolNames: string[]): Promise<void>;
777
895
 
896
+ /** Get available slash commands in the current session. */
897
+ getCommands(): SlashCommandInfo[];
898
+
778
899
  /** Set the current model. Returns false if no API key available. */
779
900
  setModel(model: Model): Promise<boolean>;
780
901
 
@@ -833,6 +954,8 @@ export type GetActiveToolsHandler = () => string[];
833
954
 
834
955
  export type GetAllToolsHandler = () => string[];
835
956
 
957
+ export type GetCommandsHandler = () => SlashCommandInfo[];
958
+
836
959
  export type SetActiveToolsHandler = (toolNames: string[]) => Promise<void>;
837
960
 
838
961
  export type SetModelHandler = (model: Model) => Promise<boolean>;
@@ -855,6 +978,7 @@ export interface ExtensionActions {
855
978
  getActiveTools: GetActiveToolsHandler;
856
979
  getAllTools: GetAllToolsHandler;
857
980
  setActiveTools: SetActiveToolsHandler;
981
+ getCommands: GetCommandsHandler;
858
982
  setModel: SetModelHandler;
859
983
  getThinkingLevel: GetThinkingLevelHandler;
860
984
  setThinkingLevel: SetThinkingLevelHandler;
@@ -869,6 +993,7 @@ export interface ExtensionContextActions {
869
993
  shutdown: () => void;
870
994
  getContextUsage: () => ContextUsage | undefined;
871
995
  compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
996
+ getSystemPrompt: () => string;
872
997
  }
873
998
 
874
999
  /** Actions for ExtensionCommandContext (ctx.* in command handlers). */
@@ -882,6 +1007,7 @@ export interface ExtensionCommandContextActions {
882
1007
  branch: (entryId: string) => Promise<{ cancelled: boolean }>;
883
1008
  navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
884
1009
  compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
1010
+ switchSession: (sessionPath: string) => Promise<{ cancelled: boolean }>;
885
1011
  }
886
1012
 
887
1013
  /** Full runtime = state + actions. */
@@ -32,7 +32,7 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
32
32
  onUpdate?: AgentToolUpdateCallback<any>,
33
33
  _context?: AgentToolContext,
34
34
  ) {
35
- return this.registeredTool.definition.execute(toolCallId, params, onUpdate, this.runner.createContext(), signal);
35
+ return this.registeredTool.definition.execute(toolCallId, params, signal, onUpdate, this.runner.createContext());
36
36
  }
37
37
 
38
38
  renderCall?(args: any, theme: any) {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Parsed git URL information.
3
+ */
4
+ export type GitSource = {
5
+ /** Always "git" for git sources */
6
+ type: "git";
7
+ /** Clone URL (always valid for git clone, without ref suffix) */
8
+ repo: string;
9
+ /** Git host domain (e.g., "github.com") */
10
+ host: string;
11
+ /** Repository path (e.g., "user/repo") */
12
+ path: string;
13
+ /** Git ref (branch, tag, commit) if specified */
14
+ ref?: string;
15
+ /** True if ref was specified (package won't be auto-updated) */
16
+ pinned: boolean;
17
+ };
18
+
19
+ /** Known git hosts and their URL extraction logic. */
20
+ const KNOWN_HOSTS: Record<string, (pathname: string, hash: string) => { user: string; project: string } | null> = {
21
+ "github.com": extractStandard,
22
+ "gitlab.com": extractGitLab,
23
+ "bitbucket.org": extractStandard,
24
+ "git.sr.ht": extractStandard,
25
+ "codeberg.org": extractStandard,
26
+ };
27
+
28
+ function stripUrlCredentials(url: string): string {
29
+ if (!url.includes("://")) return url;
30
+ try {
31
+ const parsed = new URL(url);
32
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return url;
33
+ if (!parsed.username && !parsed.password) return url;
34
+ parsed.username = "";
35
+ parsed.password = "";
36
+ return parsed.toString().replace(/\/$/, "");
37
+ } catch {
38
+ return url;
39
+ }
40
+ }
41
+
42
+ function extractStandard(pathname: string, _hash: string): { user: string; project: string } | null {
43
+ const [, user, project] = pathname.split("/", 3);
44
+ if (!user || !project) return null;
45
+ return { user, project: project.replace(/\.git$/, "") };
46
+ }
47
+
48
+ function extractGitLab(pathname: string, _hash: string): { user: string; project: string } | null {
49
+ const path = pathname.startsWith("/") ? pathname.slice(1) : pathname;
50
+ if (path.includes("/-/") || path.includes("/archive.tar.gz")) return null;
51
+ const segments = path.split("/");
52
+ let project = segments.pop();
53
+ if (!project) return null;
54
+ project = project.replace(/\.git$/, "");
55
+ const user = segments.join("/");
56
+ if (!user || !project) return null;
57
+ return { user, project };
58
+ }
59
+
60
+ /**
61
+ * Try to parse a URL against known git hosts.
62
+ * Returns `{ domain, user, project, committish }` or null.
63
+ */
64
+ function tryKnownHost(candidate: string): { domain: string; user: string; project: string; committish: string } | null {
65
+ let parsed: URL;
66
+ try {
67
+ parsed = new URL(candidate);
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const hostname = parsed.hostname.startsWith("www.") ? parsed.hostname.slice(4) : parsed.hostname;
73
+ const extractor = KNOWN_HOSTS[hostname];
74
+ if (!extractor) return null;
75
+
76
+ const segments = extractor(parsed.pathname, parsed.hash);
77
+ if (!segments) return null;
78
+
79
+ let committish = "";
80
+ if (parsed.hash) {
81
+ try {
82
+ committish = decodeURIComponent(parsed.hash.slice(1));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ return {
89
+ domain: hostname,
90
+ user: segments.user,
91
+ project: segments.project,
92
+ committish,
93
+ };
94
+ }
95
+
96
+ function splitRef(url: string): { repo: string; ref?: string } {
97
+ const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
98
+ if (scpLikeMatch) {
99
+ const pathWithMaybeRef = scpLikeMatch[2] ?? "";
100
+ const refSeparator = pathWithMaybeRef.indexOf("@");
101
+ if (refSeparator < 0) return { repo: url };
102
+ const repoPath = pathWithMaybeRef.slice(0, refSeparator);
103
+ const ref = pathWithMaybeRef.slice(refSeparator + 1);
104
+ if (!repoPath || !ref) return { repo: url };
105
+ return {
106
+ repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`,
107
+ ref,
108
+ };
109
+ }
110
+
111
+ if (url.includes("://")) {
112
+ try {
113
+ const parsed = new URL(url);
114
+ const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, "");
115
+ const refSeparator = pathWithMaybeRef.indexOf("@");
116
+ if (refSeparator < 0) return { repo: url };
117
+ const repoPath = pathWithMaybeRef.slice(0, refSeparator);
118
+ const ref = pathWithMaybeRef.slice(refSeparator + 1);
119
+ if (!repoPath || !ref) return { repo: url };
120
+ parsed.pathname = `/${repoPath}`;
121
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
122
+ parsed.username = "";
123
+ parsed.password = "";
124
+ }
125
+ return {
126
+ repo: parsed.toString().replace(/\/$/, ""),
127
+ ref,
128
+ };
129
+ } catch {
130
+ return { repo: url };
131
+ }
132
+ }
133
+
134
+ const slashIndex = url.indexOf("/");
135
+ if (slashIndex < 0) return { repo: url };
136
+ const host = url.slice(0, slashIndex);
137
+ const pathWithMaybeRef = url.slice(slashIndex + 1);
138
+ const refSeparator = pathWithMaybeRef.indexOf("@");
139
+ if (refSeparator < 0) return { repo: url };
140
+ const repoPath = pathWithMaybeRef.slice(0, refSeparator);
141
+ const ref = pathWithMaybeRef.slice(refSeparator + 1);
142
+ if (!repoPath || !ref) return { repo: url };
143
+ return { repo: `${host}/${repoPath}`, ref };
144
+ }
145
+
146
+ /** Try known-host parsing and build a GitSource from the result. */
147
+ function tryKnownHostSource(
148
+ split: { repo: string; ref?: string },
149
+ candidate: string,
150
+ repoUrl: string,
151
+ ): GitSource | null {
152
+ const info = tryKnownHost(candidate);
153
+ if (!info) return null;
154
+ if (split.ref && info.project.includes("@")) return null;
155
+ return {
156
+ type: "git",
157
+ repo: stripUrlCredentials(repoUrl),
158
+ host: info.domain,
159
+ path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
160
+ ref: info.committish || split.ref || undefined,
161
+ pinned: Boolean(info.committish || split.ref),
162
+ };
163
+ }
164
+
165
+ function parseGenericGitUrl(url: string): GitSource | null {
166
+ const { repo: repoWithoutRef, ref } = splitRef(url);
167
+ let repo = repoWithoutRef;
168
+ let host = "";
169
+ let repoPath = "";
170
+
171
+ const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/);
172
+ if (scpLikeMatch) {
173
+ host = scpLikeMatch[1] ?? "";
174
+ repoPath = scpLikeMatch[2] ?? "";
175
+ } else if (/^https?:\/\/|^ssh:\/\//.test(repoWithoutRef)) {
176
+ try {
177
+ const parsed = new URL(repoWithoutRef);
178
+ if (parsed.hash) {
179
+ try {
180
+ decodeURIComponent(parsed.hash.slice(1));
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+ host = parsed.hostname;
186
+ repoPath = parsed.pathname.replace(/^\/+/, "");
187
+ repo = stripUrlCredentials(repoWithoutRef);
188
+ } catch {
189
+ return null;
190
+ }
191
+ } else {
192
+ const slashIndex = repoWithoutRef.indexOf("/");
193
+ if (slashIndex < 0) return null;
194
+ repo = `https://${repoWithoutRef}`;
195
+ try {
196
+ const parsed = new URL(repo);
197
+ host = parsed.hostname;
198
+ repoPath = parsed.pathname.replace(/^\/+/, "");
199
+ repo = stripUrlCredentials(repo);
200
+ } catch {
201
+ return null;
202
+ }
203
+ if (!host.includes(".") && host !== "localhost") return null;
204
+ }
205
+
206
+ const normalizedPath = repoPath.replace(/\.git$/, "").replace(/^\/+/, "");
207
+ if (!host || !normalizedPath || normalizedPath.split("/").length < 2) return null;
208
+
209
+ return { type: "git", repo, host, path: normalizedPath, ref, pinned: Boolean(ref) };
210
+ }
211
+
212
+ /**
213
+ * Parse any git URL (SSH or HTTPS) into a GitSource.
214
+ *
215
+ * Handles:
216
+ * - `git:` prefixed URLs (`git:github.com/user/repo`)
217
+ * - SSH SCP-like URLs (`git@github.com:user/repo`)
218
+ * - HTTPS/HTTP/SSH protocol URLs
219
+ * - Bare `host/user/repo` shorthand
220
+ * - Ref pinning via `@ref` suffix
221
+ *
222
+ * Recognizes GitHub, GitLab, Bitbucket, Sourcehut, and Codeberg natively.
223
+ * Falls back to generic URL parsing for other hosts.
224
+ */
225
+ export function parseGitUrl(source: string): GitSource | null {
226
+ const url = source.startsWith("git:") ? source.slice(4).trim() : source;
227
+ const hashIndex = url.indexOf("#");
228
+ if (hashIndex >= 0) {
229
+ const hash = url.slice(hashIndex + 1);
230
+ if (hash) {
231
+ try {
232
+ decodeURIComponent(hash);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+ }
238
+ const split = splitRef(url);
239
+
240
+ // SCP-like SSH URLs (git@host:user/repo) — convert to https for host matching
241
+ const scpMatch = split.repo.match(/^git@([^:]+):(.+)$/);
242
+
243
+ // Try known hosts with the repo URL directly
244
+ const directCandidates: string[] = [];
245
+ if (scpMatch) {
246
+ directCandidates.push(`https://${scpMatch[1]}/${scpMatch[2]}`);
247
+ } else if (/^https?:\/\/|^ssh:\/\//.test(split.repo)) {
248
+ directCandidates.push(split.repo);
249
+ }
250
+
251
+ for (const candidate of directCandidates) {
252
+ const withRef = split.ref ? `${candidate.replace(/#.*$/, "")}#${split.ref}` : candidate;
253
+ const needsHttps =
254
+ !split.repo.startsWith("http://") &&
255
+ !split.repo.startsWith("https://") &&
256
+ !split.repo.startsWith("ssh://") &&
257
+ !split.repo.startsWith("git@");
258
+ const result = tryKnownHostSource(split, withRef, needsHttps ? `https://${split.repo}` : split.repo);
259
+ if (result) return result;
260
+ }
261
+
262
+ // Try with https:// prefix for bare host/user/repo shorthand
263
+ if (!split.repo.includes("://") && !split.repo.startsWith("git@")) {
264
+ const httpsCandidate = split.ref ? `https://${split.repo}#${split.ref}` : `https://${url}`;
265
+ const result = tryKnownHostSource(split, httpsCandidate, `https://${split.repo}`);
266
+ if (result) return result;
267
+ }
268
+
269
+ return parseGenericGitUrl(url);
270
+ }
@@ -1,5 +1,7 @@
1
1
  // Plugin system exports
2
+
2
3
  export { formatDoctorResults, runDoctorChecks } from "./doctor";
4
+ export { type GitSource, parseGitUrl } from "./git-url";
3
5
  export {
4
6
  getAllPluginCommandPaths,
5
7
  getAllPluginHookPaths,