@clinebot/core 0.0.32 → 0.0.34

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 (85) hide show
  1. package/dist/auth/client.d.ts +19 -0
  2. package/dist/auth/client.d.ts.map +1 -1
  3. package/dist/auth/cline.d.ts.map +1 -1
  4. package/dist/auth/oca.d.ts.map +1 -1
  5. package/dist/auth/server.d.ts +32 -0
  6. package/dist/auth/server.d.ts.map +1 -1
  7. package/dist/auth/types.d.ts +29 -0
  8. package/dist/auth/types.d.ts.map +1 -1
  9. package/dist/extensions/context/agentic-compaction.d.ts.map +1 -1
  10. package/dist/extensions/context/basic-compaction.d.ts.map +1 -1
  11. package/dist/extensions/context/compaction-shared.d.ts +1 -1
  12. package/dist/extensions/context/compaction-shared.d.ts.map +1 -1
  13. package/dist/extensions/context/compaction.d.ts.map +1 -1
  14. package/dist/extensions/index.d.ts +2 -1
  15. package/dist/extensions/index.d.ts.map +1 -1
  16. package/dist/extensions/plugin/plugin-config-loader.d.ts +2 -1
  17. package/dist/extensions/plugin/plugin-config-loader.d.ts.map +1 -1
  18. package/dist/extensions/plugin/plugin-load-report.d.ts +19 -0
  19. package/dist/extensions/plugin/plugin-load-report.d.ts.map +1 -0
  20. package/dist/extensions/plugin/plugin-loader.d.ts +6 -0
  21. package/dist/extensions/plugin/plugin-loader.d.ts.map +1 -1
  22. package/dist/extensions/plugin/plugin-sandbox.d.ts +2 -1
  23. package/dist/extensions/plugin/plugin-sandbox.d.ts.map +1 -1
  24. package/dist/extensions/plugin-sandbox-bootstrap.js +148 -148
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +227 -229
  28. package/dist/runtime/runtime-builder.d.ts +1 -1
  29. package/dist/runtime/runtime-builder.d.ts.map +1 -1
  30. package/dist/runtime/subprocess-sandbox.d.ts +2 -0
  31. package/dist/runtime/subprocess-sandbox.d.ts.map +1 -1
  32. package/dist/runtime/tool-approval.d.ts.map +1 -1
  33. package/dist/session/default-session-manager.d.ts.map +1 -1
  34. package/dist/session/persistence-service.d.ts.map +1 -1
  35. package/dist/session/session-agent-events.d.ts.map +1 -1
  36. package/dist/session/session-artifacts.d.ts +2 -0
  37. package/dist/session/session-artifacts.d.ts.map +1 -1
  38. package/dist/session/session-config-builder.d.ts.map +1 -1
  39. package/dist/team/team-tools.d.ts.map +1 -1
  40. package/dist/types/config.d.ts +1 -0
  41. package/dist/types/config.d.ts.map +1 -1
  42. package/dist/types/events.d.ts +4 -0
  43. package/dist/types/events.d.ts.map +1 -1
  44. package/package.json +4 -4
  45. package/src/auth/client.test.ts +29 -0
  46. package/src/auth/client.ts +21 -0
  47. package/src/auth/cline.ts +2 -0
  48. package/src/auth/oca.ts +2 -0
  49. package/src/auth/server.test.ts +287 -0
  50. package/src/auth/server.ts +50 -1
  51. package/src/auth/types.ts +29 -0
  52. package/src/extensions/context/agentic-compaction.ts +22 -10
  53. package/src/extensions/context/basic-compaction.ts +43 -18
  54. package/src/extensions/context/compaction-shared.ts +1 -1
  55. package/src/extensions/context/compaction.test.ts +16 -10
  56. package/src/extensions/context/compaction.ts +35 -12
  57. package/src/extensions/index.ts +6 -0
  58. package/src/extensions/plugin/plugin-config-loader.test.ts +37 -0
  59. package/src/extensions/plugin/plugin-config-loader.ts +18 -10
  60. package/src/extensions/plugin/plugin-load-report.ts +20 -0
  61. package/src/extensions/plugin/plugin-loader.test.ts +45 -0
  62. package/src/extensions/plugin/plugin-loader.ts +57 -3
  63. package/src/extensions/plugin/plugin-sandbox-bootstrap.ts +158 -86
  64. package/src/extensions/plugin/plugin-sandbox.test.ts +70 -0
  65. package/src/extensions/plugin/plugin-sandbox.ts +17 -6
  66. package/src/index.ts +11 -0
  67. package/src/providers/local-provider-service.test.ts +4 -4
  68. package/src/runtime/hook-file-hooks.test.ts +42 -7
  69. package/src/runtime/runtime-builder.test.ts +98 -0
  70. package/src/runtime/runtime-builder.ts +112 -65
  71. package/src/runtime/subprocess-sandbox.ts +26 -23
  72. package/src/runtime/tool-approval.ts +13 -15
  73. package/src/session/default-session-manager.ts +1 -3
  74. package/src/session/persistence-service.test.ts +38 -0
  75. package/src/session/persistence-service.ts +16 -1
  76. package/src/session/session-agent-events.ts +9 -1
  77. package/src/session/session-artifacts.ts +16 -0
  78. package/src/session/session-config-builder.ts +46 -0
  79. package/src/team/team-tools.test.ts +104 -0
  80. package/src/team/team-tools.ts +35 -16
  81. package/src/types/config.ts +1 -0
  82. package/src/types/events.ts +4 -0
  83. package/dist/runtime/team-runtime-registry.d.ts +0 -13
  84. package/dist/runtime/team-runtime-registry.d.ts.map +0 -1
  85. package/src/runtime/team-runtime-registry.ts +0 -43
@@ -19,6 +19,17 @@ function createDeferred<T>(): Deferred<T> {
19
19
  return { promise, resolve };
20
20
  }
21
21
 
22
+ export interface OAuthServerListeningInfo {
23
+ host: string;
24
+ port: number;
25
+ callbackUrl: string;
26
+ }
27
+
28
+ export interface OAuthServerCloseInfo {
29
+ host: string;
30
+ port: number;
31
+ }
32
+
22
33
  export interface LocalOAuthServerOptions {
23
34
  host?: string;
24
35
  ports: number[];
@@ -26,6 +37,29 @@ export interface LocalOAuthServerOptions {
26
37
  timeoutMs?: number;
27
38
  expectedState?: string;
28
39
  successHtml?: string;
40
+ /**
41
+ * Called when the local redirect server successfully binds to a port and is
42
+ * ready to receive the OAuth callback. Hosts can use this to display a
43
+ * "waiting for callback" status indicator or — in remote-development
44
+ * environments like JetBrains Gateway — to forward the port from the remote
45
+ * machine to the local machine where the user's browser is running.
46
+ *
47
+ * May be async; `startLocalOAuthServer` will **await** this callback before
48
+ * returning so that any setup it performs (e.g. port-forwarding) is
49
+ * guaranteed to complete before the caller opens the auth URL. Errors
50
+ * thrown by this callback are swallowed — they do not prevent the OAuth
51
+ * flow from proceeding.
52
+ */
53
+ onListening?: (info: OAuthServerListeningInfo) => void | Promise<void>;
54
+ /**
55
+ * Called when the local redirect server closes, either because the OAuth
56
+ * callback was received, the flow was cancelled, or the timeout elapsed.
57
+ * Hosts should use this to tear down any port-forward set up in
58
+ * `onListening` and clear any "waiting for callback" status UI.
59
+ *
60
+ * May be async; fired after the underlying server socket is closed.
61
+ */
62
+ onClose?: (info: OAuthServerCloseInfo) => void | Promise<void>;
29
63
  }
30
64
 
31
65
  export interface LocalOAuthServer {
@@ -48,6 +82,7 @@ export async function startLocalOAuthServer(
48
82
  let settled = false;
49
83
  let timeout: ReturnType<typeof setTimeout> | null = null;
50
84
  let activeServer: import("node:http").Server | null = null;
85
+ let boundPort: number | null = null;
51
86
 
52
87
  const settle = (value: OAuthCallbackPayload | null) => {
53
88
  if (settled) return;
@@ -60,10 +95,17 @@ export async function startLocalOAuthServer(
60
95
  clearTimeout(timeout);
61
96
  timeout = null;
62
97
  }
98
+ const closingPort = boundPort;
99
+ boundPort = null;
63
100
  if (activeServer) {
64
101
  activeServer.close();
65
102
  activeServer = null;
66
103
  }
104
+ if (closingPort !== null && options.onClose) {
105
+ void Promise.resolve(options.onClose({ host, port: closingPort })).catch(
106
+ () => {},
107
+ );
108
+ }
67
109
  };
68
110
 
69
111
  const waitForCallback = async () => {
@@ -149,8 +191,15 @@ export async function startLocalOAuthServer(
149
191
  }
150
192
 
151
193
  if (bindResult.bound) {
194
+ boundPort = port;
195
+ const callbackUrl = `http://${host}:${port}${options.callbackPath}`;
196
+ if (options.onListening) {
197
+ await Promise.resolve(
198
+ options.onListening({ host, port, callbackUrl }),
199
+ ).catch(() => {});
200
+ }
152
201
  return {
153
- callbackUrl: `http://${host}:${port}${options.callbackPath}`,
202
+ callbackUrl,
154
203
  waitForCallback,
155
204
  cancelWait: () => {
156
205
  close();
package/src/auth/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ITelemetryService } from "@clinebot/shared";
2
+ import type { OAuthServerCloseInfo, OAuthServerListeningInfo } from "./server";
2
3
 
3
4
  export interface OAuthPrompt {
4
5
  message: string;
@@ -31,6 +32,34 @@ export interface OAuthLoginCallbacks {
31
32
  onPrompt: (prompt: OAuthPrompt) => Promise<string>;
32
33
  onProgress?: (message: string) => void;
33
34
  onManualCodeInput?: () => Promise<string>;
35
+ /**
36
+ * Called when the local OAuth redirect server successfully binds to a port
37
+ * and is ready to receive the browser callback. The `info` object contains
38
+ * the host, the bound port number, and the full `callbackUrl`.
39
+ *
40
+ * Use this to:
41
+ * - Show a "waiting for OAuth callback on port N" status indicator in your UI.
42
+ * - Forward the port in remote-development environments (e.g. JetBrains
43
+ * Gateway) from the remote machine to the machine running the browser.
44
+ *
45
+ * Paired with `onServerClose` for teardown.
46
+ *
47
+ * Only fired when the provider uses a local callback server
48
+ * (`OAuthProviderInterface.usesCallbackServer === true`).
49
+ */
50
+ onServerListening?: (info: OAuthServerListeningInfo) => void | Promise<void>;
51
+ /**
52
+ * Called when the local OAuth redirect server closes — either because the
53
+ * callback was received, the flow was cancelled, or the timeout elapsed.
54
+ *
55
+ * Use this to:
56
+ * - Clear any "waiting for callback" status UI shown in `onServerListening`.
57
+ * - Tear down port-forwards set up in `onServerListening`.
58
+ *
59
+ * Only fired when the provider uses a local callback server
60
+ * (`OAuthProviderInterface.usesCallbackServer === true`).
61
+ */
62
+ onServerClose?: (info: OAuthServerCloseInfo) => void | Promise<void>;
34
63
  }
35
64
 
36
65
  export interface OAuthProviderInterface {
@@ -106,14 +106,26 @@ export async function runAgenticCompaction(options: {
106
106
  (total, message) => total + options.estimateMessageTokens(message),
107
107
  0,
108
108
  );
109
- return {
110
- messages: [
111
- buildSummaryMessage({
112
- summary,
113
- fileOps,
114
- tokensBefore,
115
- }),
116
- ...messages.slice(cutIndex),
117
- ],
118
- };
109
+ const resultMessages = [
110
+ buildSummaryMessage({
111
+ summary,
112
+ fileOps,
113
+ tokensBefore,
114
+ }),
115
+ ...messages.slice(cutIndex),
116
+ ];
117
+ const tokensAfter = resultMessages.reduce(
118
+ (total, message) => total + options.estimateMessageTokens(message),
119
+ 0,
120
+ );
121
+ options.logger?.debug("Performed agentic compaction", {
122
+ messagesBefore: messages.length,
123
+ messagesAfter: resultMessages.length,
124
+ messagesSummarized: cutIndex,
125
+ messagesPreserved: messages.length - cutIndex,
126
+ tokensBefore,
127
+ tokensAfter,
128
+ contextWindowTokens: options.context.contextWindowTokens,
129
+ });
130
+ return { messages: resultMessages };
119
131
  }
@@ -33,17 +33,19 @@ function sanitizeMessageForBasic(
33
33
  return text ? { ...message, content: text } : undefined;
34
34
  }
35
35
 
36
- const textParts: string[] = [];
37
- for (const block of message.content) {
38
- if (block.type === "text") {
39
- const text = block.text.trim();
40
- if (text) {
41
- textParts.push(text);
42
- }
43
- }
36
+ // Preserve array structure: keep only text blocks with non-empty content.
37
+ const kept = message.content.filter(
38
+ (block) => block.type === "text" && block.text.trim(),
39
+ );
40
+ if (kept.length === 0) {
41
+ return undefined;
44
42
  }
45
- const text = textParts.join("\n\n").trim();
46
- return text ? { ...message, content: text } : undefined;
43
+ return {
44
+ ...message,
45
+ content: kept.map((block) =>
46
+ block.type === "text" ? { ...block, text: block.text.trim() } : block,
47
+ ),
48
+ };
47
49
  }
48
50
 
49
51
  function getTotalTokens(
@@ -60,14 +62,25 @@ function truncateMessageToTokens(
60
62
  message: MessageWithMetadata,
61
63
  maxTokens: number,
62
64
  ): MessageWithMetadata {
63
- const content = typeof message.content === "string" ? message.content : "";
64
65
  const safeMaxTokens = Math.max(1, maxTokens);
65
66
  const targetChars = Math.max(16, safeMaxTokens * 4);
66
- const truncated = truncateText(content, targetChars).trim();
67
- return {
68
- ...message,
69
- content: truncated || "...",
70
- };
67
+
68
+ if (typeof message.content === "string") {
69
+ const truncated = truncateText(message.content, targetChars).trim();
70
+ return { ...message, content: truncated || "..." };
71
+ }
72
+
73
+ // Preserve content block array structure while truncating text blocks.
74
+ let remaining = targetChars;
75
+ const truncatedBlocks = message.content.map((block) => {
76
+ if (block.type !== "text" || remaining <= 0) {
77
+ return block;
78
+ }
79
+ const truncated = truncateText(block.text, remaining).trim();
80
+ remaining -= truncated.length;
81
+ return { ...block, text: truncated || "..." };
82
+ });
83
+ return { ...message, content: truncatedBlocks };
71
84
  }
72
85
 
73
86
  function buildBasicCandidates(
@@ -265,10 +278,22 @@ export function runBasicCompaction(options: {
265
278
  return undefined;
266
279
  }
267
280
 
281
+ const beforeTokens = getTotalTokens(
282
+ options.context.messages.map((m) => sanitizeMessageForBasic(m) ?? m),
283
+ options.estimateMessageTokens,
284
+ );
285
+ const afterTokens = getTotalTokens(
286
+ nextMessages,
287
+ options.estimateMessageTokens,
288
+ );
268
289
  options.logger?.debug("Performed basic compaction", {
269
- beforeCount: options.context.messages.length,
270
- afterCount: nextMessages.length,
290
+ messagesBefore: options.context.messages.length,
291
+ messagesAfter: nextMessages.length,
292
+ messagesRemoved: options.context.messages.length - nextMessages.length,
293
+ tokensBefore: beforeTokens,
294
+ tokensAfter: afterTokens,
271
295
  targetTokens,
296
+ contextWindowTokens: options.context.contextWindowTokens,
272
297
  });
273
298
 
274
299
  return { messages: nextMessages };
@@ -6,7 +6,7 @@ import type {
6
6
  } from "../../types/config";
7
7
 
8
8
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000;
9
- export const DEFAULT_THRESHOLD_RATIO = 0.8;
9
+ export const DEFAULT_THRESHOLD_RATIO = 0.95;
10
10
  export const DEFAULT_RESERVE_TOKENS = 16_384;
11
11
  export const DEFAULT_PRESERVE_RECENT_TOKENS = 20_000;
12
12
  export const DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS = 1_024;
@@ -363,16 +363,22 @@ describe("createContextCompactionPrepareTurn", () => {
363
363
  );
364
364
  expect(result?.messages).toBeDefined();
365
365
  expect(result?.messages.length).toBeGreaterThan(0);
366
- expect(
367
- result?.messages.every((message) => typeof message.content === "string"),
368
- ).toBe(true);
369
- expect(
370
- result?.messages.some((message) =>
371
- typeof message.content === "string"
372
- ? message.content.includes("tool output that should be removed")
373
- : false,
374
- ),
375
- ).toBe(false);
366
+ // Compacted messages should not contain tool_result content that was pruned.
367
+ for (const message of result?.messages ?? []) {
368
+ if (typeof message.content === "string") {
369
+ expect(message.content).not.toContain(
370
+ "tool output that should be removed",
371
+ );
372
+ } else {
373
+ for (const block of message.content) {
374
+ if (block.type === "text") {
375
+ expect(block.text).not.toContain(
376
+ "tool output that should be removed",
377
+ );
378
+ }
379
+ }
380
+ }
381
+ }
376
382
  });
377
383
 
378
384
  it("defaults to threshold ratio when reserveTokens is not configured", async () => {
@@ -185,19 +185,42 @@ export function createContextCompactionPrepareTurn(
185
185
  contextWindowTokens,
186
186
  });
187
187
 
188
- if (userCompaction?.compact) {
189
- return await userCompaction.compact(compactionContext);
188
+ const beforeMessageCount = context.messages.length;
189
+
190
+ const result = userCompaction?.compact
191
+ ? await userCompaction.compact(compactionContext)
192
+ : await runBuiltinStrategy({
193
+ context: compactionContext,
194
+ providerConfig: {
195
+ ...providerConfig,
196
+ abortSignal: context.abortSignal,
197
+ },
198
+ compaction: userCompaction,
199
+ estimateMessageTokens,
200
+ logger: config.logger,
201
+ });
202
+
203
+ if (result?.messages) {
204
+ const afterTokens = result.messages.reduce(
205
+ (total: number, message) => total + estimateMessageTokens(message),
206
+ 0,
207
+ );
208
+ config.logger?.log("Context compaction completed", {
209
+ severity: "info",
210
+ strategy: strategy,
211
+ contextWindowTokens,
212
+ inputTokens,
213
+ afterTokens,
214
+ tokensSaved: inputTokens - afterTokens,
215
+ utilizationBefore: `${((inputTokens / contextWindowTokens) * 100).toFixed(1)}%`,
216
+ utilizationAfter: `${((afterTokens / contextWindowTokens) * 100).toFixed(1)}%`,
217
+ thresholdTrigger: `${(triggerState.thresholdRatio * 100).toFixed(1)}%`,
218
+ messagesBefore: beforeMessageCount,
219
+ messagesAfter: result.messages.length,
220
+ messagesRemoved: beforeMessageCount - result.messages.length,
221
+ } as Record<string, unknown>);
190
222
  }
191
223
 
192
- return await runBuiltinStrategy({
193
- context: compactionContext,
194
- providerConfig: {
195
- ...providerConfig,
196
- abortSignal: context.abortSignal,
197
- },
198
- compaction: userCompaction,
199
- estimateMessageTokens,
200
- logger: config.logger,
201
- });
224
+ return result;
202
225
  };
203
226
  }
@@ -5,8 +5,14 @@ export {
5
5
  resolveAndLoadAgentPlugins,
6
6
  resolvePluginConfigSearchPaths,
7
7
  } from "./plugin/plugin-config-loader";
8
+ export type {
9
+ PluginInitializationFailure,
10
+ PluginInitializationWarning,
11
+ PluginLoadDiagnostics,
12
+ } from "./plugin/plugin-load-report";
8
13
  export type { LoadAgentPluginFromPathOptions } from "./plugin/plugin-loader";
9
14
  export {
10
15
  loadAgentPluginFromPath,
11
16
  loadAgentPluginsFromPaths,
17
+ loadAgentPluginsFromPathsWithDiagnostics,
12
18
  } from "./plugin/plugin-loader";
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
6
6
  import {
7
7
  discoverPluginModulePaths,
8
8
  resolveAgentPluginPaths,
9
+ resolveAndLoadAgentPlugins,
9
10
  resolvePluginConfigSearchPaths,
10
11
  } from "./plugin-config-loader";
11
12
 
@@ -142,4 +143,40 @@ describe("plugin-config-loader", () => {
142
143
  await rm(workspace, { recursive: true, force: true });
143
144
  }
144
145
  });
146
+
147
+ it("loads valid plugins while reporting failures and duplicate overrides", async () => {
148
+ const root = await mkdtemp(join(tmpdir(), "core-plugin-config-loader-"));
149
+ try {
150
+ const first = join(root, "duplicate-one.js");
151
+ const second = join(root, "duplicate-two.js");
152
+ const invalid = join(root, "invalid.js");
153
+ await writeFile(
154
+ first,
155
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['tools'] } };",
156
+ "utf8",
157
+ );
158
+ await writeFile(
159
+ second,
160
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['commands'] } };",
161
+ "utf8",
162
+ );
163
+ await writeFile(invalid, "export default { name: 'broken' };", "utf8");
164
+
165
+ const loaded = await resolveAndLoadAgentPlugins({
166
+ mode: "in_process",
167
+ pluginPaths: [first, invalid, second],
168
+ cwd: root,
169
+ });
170
+
171
+ expect(loaded.extensions.map((plugin) => plugin.name)).toEqual([
172
+ "duplicate-plugin",
173
+ ]);
174
+ expect(loaded.extensions[0]?.manifest.capabilities).toEqual(["commands"]);
175
+ expect(loaded.failures).toHaveLength(1);
176
+ expect(loaded.warnings).toHaveLength(1);
177
+ expect(loaded.warnings[0]?.overriddenPluginPath).toBe(first);
178
+ } finally {
179
+ await rm(root, { recursive: true, force: true });
180
+ }
181
+ });
145
182
  });
@@ -5,7 +5,8 @@ import {
5
5
  resolveConfiguredPluginModulePaths,
6
6
  resolvePluginConfigSearchPaths as resolvePluginConfigSearchPathsFromShared,
7
7
  } from "@clinebot/shared/storage";
8
- import { loadAgentPluginsFromPaths } from "./plugin-loader";
8
+ import type { PluginLoadDiagnostics } from "./plugin-load-report";
9
+ import { loadAgentPluginsFromPathsWithDiagnostics } from "./plugin-loader";
9
10
  import { loadSandboxedPlugins } from "./plugin-sandbox";
10
11
 
11
12
  type AgentPlugin = NonNullable<AgentConfig["extensions"]>[number];
@@ -64,21 +65,26 @@ export interface ResolveAndLoadAgentPluginsOptions
64
65
 
65
66
  export async function resolveAndLoadAgentPlugins(
66
67
  options: ResolveAndLoadAgentPluginsOptions = {},
67
- ): Promise<{
68
- extensions: AgentPlugin[];
69
- shutdown?: () => Promise<void>;
70
- }> {
68
+ ): Promise<
69
+ {
70
+ extensions: AgentPlugin[];
71
+ shutdown?: () => Promise<void>;
72
+ } & PluginLoadDiagnostics
73
+ > {
71
74
  const paths = resolveAgentPluginPaths(options);
72
75
  if (paths.length === 0) {
73
- return { extensions: [] };
76
+ return { extensions: [], failures: [], warnings: [] };
74
77
  }
75
78
 
76
79
  if (options.mode === "in_process") {
80
+ const report = await loadAgentPluginsFromPathsWithDiagnostics(paths, {
81
+ cwd: options.cwd,
82
+ exportName: options.exportName,
83
+ });
77
84
  return {
78
- extensions: await loadAgentPluginsFromPaths(paths, {
79
- cwd: options.cwd,
80
- exportName: options.exportName,
81
- }),
85
+ extensions: report.plugins,
86
+ failures: report.failures,
87
+ warnings: report.warnings,
82
88
  };
83
89
  }
84
90
 
@@ -93,5 +99,7 @@ export async function resolveAndLoadAgentPlugins(
93
99
  return {
94
100
  extensions: sandboxed.extensions ?? [],
95
101
  shutdown: sandboxed.shutdown,
102
+ failures: sandboxed.failures,
103
+ warnings: sandboxed.warnings,
96
104
  };
97
105
  }
@@ -0,0 +1,20 @@
1
+ export interface PluginInitializationFailure {
2
+ pluginPath: string;
3
+ pluginName?: string;
4
+ phase: "load" | "setup";
5
+ message: string;
6
+ stack?: string;
7
+ }
8
+
9
+ export interface PluginInitializationWarning {
10
+ type: "duplicate_plugin_override";
11
+ pluginPath: string;
12
+ pluginName: string;
13
+ overriddenPluginPath: string;
14
+ message: string;
15
+ }
16
+
17
+ export interface PluginLoadDiagnostics {
18
+ failures: PluginInitializationFailure[];
19
+ warnings: PluginInitializationWarning[];
20
+ }
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
5
  import {
6
6
  loadAgentPluginFromPath,
7
7
  loadAgentPluginsFromPaths,
8
+ loadAgentPluginsFromPathsWithDiagnostics,
8
9
  } from "./plugin-loader";
9
10
 
10
11
  describe("plugin-loader", () => {
@@ -158,6 +159,17 @@ describe("plugin-loader", () => {
158
159
  "export default { name: 'invalid-plugin' };",
159
160
  "utf8",
160
161
  );
162
+
163
+ await writeFile(
164
+ join(dir, "duplicate-one.mjs"),
165
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['tools'] } };",
166
+ "utf8",
167
+ );
168
+ await writeFile(
169
+ join(dir, "duplicate-two.mjs"),
170
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['commands'] } };",
171
+ "utf8",
172
+ );
161
173
  });
162
174
 
163
175
  afterAll(async () => {
@@ -244,4 +256,37 @@ describe("plugin-loader", () => {
244
256
  loadAgentPluginFromPath(join(dir, "invalid-plugin.mjs")),
245
257
  ).rejects.toThrow(/missing required "manifest"/i);
246
258
  });
259
+
260
+ it("continues loading valid plugins when one plugin fails", async () => {
261
+ const report = await loadAgentPluginsFromPathsWithDiagnostics([
262
+ join(dir, "plugin-a.mjs"),
263
+ join(dir, "invalid-plugin.mjs"),
264
+ join(dir, "plugin-b.mjs"),
265
+ ]);
266
+
267
+ expect(report.plugins.map((plugin) => plugin.name)).toEqual([
268
+ "plugin-a",
269
+ "plugin-b",
270
+ ]);
271
+ expect(report.failures).toHaveLength(1);
272
+ expect(report.failures[0]?.pluginPath).toBe(
273
+ join(dir, "invalid-plugin.mjs"),
274
+ );
275
+ expect(report.warnings).toEqual([]);
276
+ });
277
+
278
+ it("keeps the later duplicate plugin and reports the override", async () => {
279
+ const report = await loadAgentPluginsFromPathsWithDiagnostics([
280
+ join(dir, "duplicate-one.mjs"),
281
+ join(dir, "duplicate-two.mjs"),
282
+ ]);
283
+
284
+ expect(report.plugins).toHaveLength(1);
285
+ expect(report.plugins[0]?.name).toBe("duplicate-plugin");
286
+ expect(report.plugins[0]?.manifest.capabilities).toEqual(["commands"]);
287
+ expect(report.warnings).toHaveLength(1);
288
+ expect(report.warnings[0]?.overriddenPluginPath).toBe(
289
+ join(dir, "duplicate-one.mjs"),
290
+ );
291
+ });
247
292
  });
@@ -1,5 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { AgentConfig } from "@clinebot/shared";
3
+ import type {
4
+ PluginInitializationFailure,
5
+ PluginInitializationWarning,
6
+ } from "./plugin-load-report";
3
7
  import { importPluginModule } from "./plugin-module-import";
4
8
 
5
9
  type AgentPlugin = NonNullable<AgentConfig["extensions"]>[number];
@@ -98,9 +102,59 @@ export async function loadAgentPluginsFromPaths(
98
102
  pluginPaths: string[],
99
103
  options: LoadAgentPluginFromPathOptions = {},
100
104
  ): Promise<AgentPlugin[]> {
101
- const loaded: AgentPlugin[] = [];
105
+ const report = await loadAgentPluginsFromPathsWithDiagnostics(
106
+ pluginPaths,
107
+ options,
108
+ );
109
+ return report.plugins;
110
+ }
111
+
112
+ export async function loadAgentPluginsFromPathsWithDiagnostics(
113
+ pluginPaths: string[],
114
+ options: LoadAgentPluginFromPathOptions = {},
115
+ ): Promise<{
116
+ plugins: AgentPlugin[];
117
+ failures: PluginInitializationFailure[];
118
+ warnings: PluginInitializationWarning[];
119
+ }> {
120
+ const failures: PluginInitializationFailure[] = [];
121
+ const warnings: PluginInitializationWarning[] = [];
122
+ const loadedByName = new Map<
123
+ string,
124
+ { plugin: AgentPlugin; pluginPath: string; order: number }
125
+ >();
126
+ let order = 0;
127
+
102
128
  for (const pluginPath of pluginPaths) {
103
- loaded.push(await loadAgentPluginFromPath(pluginPath, options));
129
+ try {
130
+ const plugin = await loadAgentPluginFromPath(pluginPath, options);
131
+ const existing = loadedByName.get(plugin.name);
132
+ if (existing) {
133
+ warnings.push({
134
+ type: "duplicate_plugin_override",
135
+ pluginName: plugin.name,
136
+ pluginPath,
137
+ overriddenPluginPath: existing.pluginPath,
138
+ message: `Plugin "${plugin.name}" from ${pluginPath} overrides ${existing.pluginPath}`,
139
+ });
140
+ }
141
+ loadedByName.set(plugin.name, { plugin, pluginPath, order: order++ });
142
+ } catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ failures.push({
145
+ pluginPath,
146
+ phase: "load",
147
+ message,
148
+ stack: error instanceof Error ? error.stack : undefined,
149
+ });
150
+ }
104
151
  }
105
- return loaded;
152
+
153
+ return {
154
+ plugins: [...loadedByName.values()]
155
+ .sort((left, right) => left.order - right.order)
156
+ .map((entry) => entry.plugin),
157
+ failures,
158
+ warnings,
159
+ };
106
160
  }