@pikiloom/kernel 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -250,7 +250,28 @@ add an IM channel or your own UI.
250
250
  | `Catalog` | `NoopCatalog` | model/effort/tool/skill discovery for composers |
251
251
  | `InteractionHandler` | `DeferToTerminalInteractionHandler` | programmatic HITL answers (`AutoCancelInteractionHandler` for one-shots) |
252
252
 
253
- **Plugins** contribute per-session tools (`tools()`) and/or augment snapshots (`decorateSnapshot()`).
253
+ **Plugins** are the registration unit for everything ONE capability adds to a session —
254
+ register many, composed deterministically (and dynamically via `loom.registerPlugin(...)`):
255
+
256
+ ```ts
257
+ interface Plugin {
258
+ id: string;
259
+ tools?(opts: { agent; workdir }): McpServerSpec[]; // MCP servers
260
+ promptFragment?(opts: { agent; workdir; isFirstTurn }): string | null; // how-to-use / behavior prompt
261
+ contributeSpawn?(opts: { agent; workdir; mode: 'run'|'tui'; sessionId?; model? }): SpawnContribution | null; // { env?, extraArgs?, configOverrides? }
262
+ decorateSnapshot?(snapshot): UniversalSnapshot;
263
+ }
264
+ ```
265
+
266
+ The kernel merges contributions per spawn — **never mutating global `process.env`** — in order
267
+ `[ModelResolver → ToolProvider.env → plugins (registration order)]`, so a plugin can override
268
+ the resolver (e.g. point an agent's `ANTHROPIC_BASE_URL` at a local proxy). `promptFragment`s are
269
+ appended to the `SystemPromptBuilder` base and delivered via each agent's native mechanism. This
270
+ is how a capability registers its tools **and** their usage prompt **and** any env/flags in one
271
+ place — and how a model-traffic interceptor injects a redirect on both the `run()` and `tui()`
272
+ rails without the kernel knowing anything about it. (The singular `ModelResolver` /
273
+ `SystemPromptBuilder` ports remain the one authoritative model-credential / base-prompt source;
274
+ plugins are the composable per-capability layer on top.)
254
275
 
255
276
  ---
256
277
 
@@ -264,7 +285,7 @@ Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel
264
285
  - Surfaces: `WebSurface`, `CliSurface`
265
286
  - Ports/defaults: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`, `DeferToTerminalInteractionHandler`, `NoopCatalog`, `defaultBaseDir`
266
287
  - Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, all wire/`Client*`/`Server*` message types
267
- - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
288
+ - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
268
289
 
269
290
  ---
270
291
 
@@ -65,11 +65,28 @@ export interface Surface {
65
65
  start(io: LoomIO, host?: TuiHost): Promise<void>;
66
66
  stop(): Promise<void>;
67
67
  }
68
+ export interface SpawnContribution {
69
+ env?: Record<string, string>;
70
+ extraArgs?: string[];
71
+ configOverrides?: string[];
72
+ }
68
73
  export interface Plugin {
69
74
  readonly id: string;
70
75
  tools?(opts: {
71
76
  agent: string;
72
77
  workdir: string;
73
78
  }): McpServerSpec[] | Promise<McpServerSpec[]>;
79
+ promptFragment?(opts: {
80
+ agent: string;
81
+ workdir: string;
82
+ isFirstTurn: boolean;
83
+ }): string | null | undefined | Promise<string | null | undefined>;
84
+ contributeSpawn?(opts: {
85
+ agent: string;
86
+ workdir: string;
87
+ mode: 'run' | 'tui';
88
+ sessionId?: string | null;
89
+ model?: string | null;
90
+ }): SpawnContribution | null | undefined | Promise<SpawnContribution | null | undefined>;
74
91
  decorateSnapshot?(snapshot: UniversalSnapshot): UniversalSnapshot;
75
92
  }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { PtyBridge, ptyAvailable, type PtyExit, type PtyOpenOpts } from './runti
6
6
  export { attachTui, type AttachTuiOptions } from './runtime/tui.js';
7
7
  export type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, SteerFn, McpServerSpec, TuiInput, TuiSpec, } from './contracts/driver.js';
8
8
  export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, ToolProvider, SystemPromptBuilder, InteractionHandler, Catalog, } from './contracts/ports.js';
9
- export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, } from './contracts/surface.js';
9
+ export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, SpawnContribution, } from './contracts/surface.js';
10
10
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
11
11
  export { EchoDriver } from './drivers/echo.js';
12
12
  export { ClaudeDriver } from './drivers/claude.js';
@@ -49,6 +49,9 @@ export declare class Hub implements LoomIO {
49
49
  private queueView;
50
50
  private publishQueued;
51
51
  private collectTools;
52
+ private mergeSpawn;
53
+ private pluginSpawn;
54
+ private composeSystemPrompt;
52
55
  private onRunnerUpdate;
53
56
  stop(sessionKey: string): boolean;
54
57
  steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
@@ -45,7 +45,11 @@ export class Hub {
45
45
  throw new Error(`Driver "${agent}" does not support TUI mode`);
46
46
  const workdir = opts.workdir || this.deps.workdir;
47
47
  const injection = await this.deps.modelResolver.resolve(agent, { model: opts.model, profileId: null }).catch(() => null);
48
- return driver.tui({ workdir, model: injection?.model ?? opts.model ?? null, sessionId: opts.sessionId ?? null, env: injection?.env });
48
+ const model = injection?.model ?? opts.model ?? null;
49
+ // Same merge as run(), so the raw-PTY rail also gets plugin env/args (e.g. a hijack redirect).
50
+ const pluginParts = await this.pluginSpawn(agent, workdir, 'tui', opts.sessionId ?? null, model);
51
+ const spawn = this.mergeSpawn([{ env: injection?.env, extraArgs: injection?.extraArgs }, ...pluginParts]);
52
+ return driver.tui({ workdir, model, sessionId: opts.sessionId ?? null, env: spawn.env, extraArgs: spawn.extraArgs });
49
53
  }
50
54
  async prompt(input) {
51
55
  const agent = (input.agent || this.deps.defaultAgent || '').trim();
@@ -98,20 +102,28 @@ export class Hub {
98
102
  // prior turn's native session id and any refreshed credentials/tools.
99
103
  const rec = await this.deps.sessionStore.get(agent, sessionId);
100
104
  const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
101
- const tools = await this.collectTools(agent, workdir, workspacePath);
102
105
  const model = injection?.model ?? input.model ?? null;
103
106
  const effort = input.effort ?? null;
104
- const systemPrompt = this.deps.systemPromptBuilder.compose({ agent, base: this.deps.systemPromptBase, isFirstTurn: !preExisted });
107
+ const tools = await this.collectTools(agent, workdir, workspacePath);
108
+ const pluginParts = await this.pluginSpawn(agent, workdir, 'run', sessionId, model);
109
+ // precedence: ModelResolver (model/creds) -> ToolProvider.env -> plugins (last, so a
110
+ // model-traffic hijack plugin can override the resolver's base URL).
111
+ const spawn = this.mergeSpawn([
112
+ { env: injection?.env, extraArgs: injection?.extraArgs, configOverrides: injection?.configOverrides },
113
+ { env: tools.env },
114
+ ...pluginParts,
115
+ ]);
116
+ const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted);
105
117
  const turnInput = {
106
118
  prompt: input.prompt,
107
119
  attachments: input.attachments,
108
120
  sessionId: rec?.nativeSessionId || (input.sessionKey ? sessionId : null),
109
121
  workdir,
110
122
  model, effort, systemPrompt,
111
- env: injection?.env,
112
- extraArgs: injection?.extraArgs, // BYOK flags from the ModelResolver
113
- configOverrides: injection?.configOverrides, // codex BYOK -c routing
114
- extraMcpServers: tools,
123
+ env: spawn.env,
124
+ extraArgs: spawn.extraArgs,
125
+ configOverrides: spawn.configOverrides,
126
+ extraMcpServers: tools.servers,
115
127
  };
116
128
  runner.run(driver, turnInput, input.prompt, model, effort)
117
129
  .then(async (result) => {
@@ -154,11 +166,16 @@ export class Hub {
154
166
  if (entry?.runner)
155
167
  this.onRunnerUpdate(sessionKey, entry.runner.snapshot, entry.runner.currentSeq);
156
168
  }
169
+ // MCP servers (ToolProvider + each plugin) PLUS the ToolProvider's session env (previously
170
+ // dropped) — surfaced so the spawn env merge can apply it.
157
171
  async collectTools(agent, workdir, workspacePath) {
158
- const out = [];
172
+ const servers = [];
173
+ let env;
159
174
  try {
160
175
  const base = await this.deps.toolProvider.provideForSession({ agent, workdir, workspacePath });
161
- out.push(...base.servers);
176
+ servers.push(...base.servers);
177
+ if (base.env && Object.keys(base.env).length)
178
+ env = base.env;
162
179
  }
163
180
  catch (e) {
164
181
  this.deps.log?.(`[hub] toolProvider failed: ${e?.message || e}`);
@@ -167,14 +184,71 @@ export class Hub {
167
184
  if (!plugin.tools)
168
185
  continue;
169
186
  try {
170
- out.push(...(await plugin.tools({ agent, workdir })));
187
+ servers.push(...(await plugin.tools({ agent, workdir })));
171
188
  }
172
189
  catch (e) {
173
190
  this.deps.log?.(`[hub] plugin ${plugin.id} tools failed: ${e?.message || e}`);
174
191
  }
175
192
  }
193
+ return { servers, env };
194
+ }
195
+ // Merge ordered spawn contributions: env keys later-wins, extraArgs/configOverrides concatenate.
196
+ // Callers order parts [ModelResolver, ToolProvider.env, ...plugins] so a plugin (e.g. a model-
197
+ // traffic hijack) can override the resolver's base URL. Never touches global process.env.
198
+ mergeSpawn(parts) {
199
+ let env;
200
+ const extraArgs = [];
201
+ const configOverrides = [];
202
+ for (const p of parts) {
203
+ if (!p)
204
+ continue;
205
+ if (p.env && Object.keys(p.env).length)
206
+ env = { ...(env || {}), ...p.env };
207
+ if (p.extraArgs?.length)
208
+ extraArgs.push(...p.extraArgs);
209
+ if (p.configOverrides?.length)
210
+ configOverrides.push(...p.configOverrides);
211
+ }
212
+ return { env, extraArgs: extraArgs.length ? extraArgs : undefined, configOverrides: configOverrides.length ? configOverrides : undefined };
213
+ }
214
+ async pluginSpawn(agent, workdir, mode, sessionId, model) {
215
+ const out = [];
216
+ for (const plugin of this.deps.plugins) {
217
+ if (!plugin.contributeSpawn)
218
+ continue;
219
+ try {
220
+ const c = await plugin.contributeSpawn({ agent, workdir, mode, sessionId, model });
221
+ if (c)
222
+ out.push(c);
223
+ }
224
+ catch (e) {
225
+ this.deps.log?.(`[hub] plugin ${plugin.id} contributeSpawn failed: ${e?.message || e}`);
226
+ }
227
+ }
176
228
  return out;
177
229
  }
230
+ // Final system/developer prompt = the singular SystemPromptBuilder base + each plugin's
231
+ // promptFragment (in registration order), joined. Delivered via AgentTurnInput.systemPrompt,
232
+ // which each driver applies its own way (claude --append-system-prompt / codex / gemini).
233
+ async composeSystemPrompt(agent, workdir, isFirstTurn) {
234
+ const parts = [];
235
+ const base = this.deps.systemPromptBuilder.compose({ agent, base: this.deps.systemPromptBase, isFirstTurn });
236
+ if (base && base.trim())
237
+ parts.push(base);
238
+ for (const plugin of this.deps.plugins) {
239
+ if (!plugin.promptFragment)
240
+ continue;
241
+ try {
242
+ const f = await plugin.promptFragment({ agent, workdir, isFirstTurn });
243
+ if (f && f.trim())
244
+ parts.push(f);
245
+ }
246
+ catch (e) {
247
+ this.deps.log?.(`[hub] plugin ${plugin.id} promptFragment failed: ${e?.message || e}`);
248
+ }
249
+ }
250
+ return parts.length ? parts.join('\n\n') : undefined;
251
+ }
178
252
  onRunnerUpdate(sessionKey, snapshot, _seq) {
179
253
  const entry = this.sessions.get(sessionKey);
180
254
  if (!entry)
@@ -28,6 +28,7 @@ export interface LoomConfig {
28
28
  export interface Loom {
29
29
  readonly io: LoomIO;
30
30
  registerDriver(driver: AgentDriver): void;
31
+ registerPlugin(plugin: Plugin): void;
31
32
  start(): Promise<void>;
32
33
  stop(): Promise<void>;
33
34
  status(): {
@@ -10,6 +10,8 @@ export function createLoom(config = {}) {
10
10
  for (const d of config.drivers || [])
11
11
  drivers.set(d.id, d);
12
12
  const defaultAgent = config.defaultAgent || config.drivers?.[0]?.id || 'echo';
13
+ // Held as a live reference so registerPlugin() mutates the same array the Hub iterates.
14
+ const plugins = [...(config.plugins || [])];
13
15
  const hub = new Hub({
14
16
  drivers,
15
17
  defaultAgent,
@@ -21,7 +23,7 @@ export function createLoom(config = {}) {
21
23
  catalog: config.catalog || new NoopCatalog(),
22
24
  interactionHandler: config.interactionHandler || new DeferToTerminalInteractionHandler(),
23
25
  serialPerSession: config.serialPerSession,
24
- plugins: config.plugins || [],
26
+ plugins,
25
27
  systemPromptBase: config.systemPromptBase,
26
28
  log,
27
29
  });
@@ -36,6 +38,7 @@ export function createLoom(config = {}) {
36
38
  return {
37
39
  io: hub,
38
40
  registerDriver(driver) { drivers.set(driver.id, driver); },
41
+ registerPlugin(plugin) { plugins.push(plugin); },
39
42
  async start() {
40
43
  if (started)
41
44
  return;
package/llms.txt CHANGED
@@ -86,7 +86,9 @@ Write a driver: implement `AgentDriver { id; capabilities?; run(input, ctx); tui
86
86
  ## Surfaces & Ports
87
87
 
88
88
  - Surface (上层, binds LoomIO): built-in `WebSurface` (ws host, wire protocol), `CliSurface`. Implement `Surface { id; capabilities?; start(io, host?); stop() }` for an IM channel.
89
- - Plugin: `{ id; tools?({agent,workdir}); decorateSnapshot?(snapshot) }`.
89
+ - Plugin = the registration unit for everything ONE capability adds to a session (register via `createLoom({plugins})` or `loom.registerPlugin(p)`):
90
+ `{ id; tools?({agent,workdir})=>McpServerSpec[]; promptFragment?({agent,workdir,isFirstTurn})=>string|null; contributeSpawn?({agent,workdir,mode:'run'|'tui',sessionId?,model?})=>SpawnContribution|null; decorateSnapshot?(snap) }`.
91
+ `SpawnContribution = { env?; extraArgs?; configOverrides? }`. Kernel merges per-spawn (NEVER touches global process.env), order `[ModelResolver → ToolProvider.env → plugins]` (plugin overrides resolver — e.g. a hijack redirecting `ANTHROPIC_BASE_URL` to a local proxy, applied on BOTH run() and tui()). `promptFragment`s append to the SystemPromptBuilder base. So tools + their usage-prompt + env/flags register in ONE place. The singular ModelResolver/SystemPromptBuilder remain the authoritative model-cred/base-prompt source; plugins are the composable layer on top.
90
92
  - Ports (defaults in parens): SessionStore (FsSessionStore), ModelResolver (NullModelResolver = native login; override for BYOK), ToolProvider (NoopToolProvider), SystemPromptBuilder (PassthroughSystemPromptBuilder), Catalog (NoopCatalog), InteractionHandler (DeferToTerminalInteractionHandler; AutoCancelInteractionHandler for one-shots). `defaultBaseDir(ns)`.
91
93
 
92
94
  ## Gotchas (read before using)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",