@pikiloom/kernel 0.1.2 → 0.1.4

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
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec } from '../contracts/driver.js';
2
- import type { UniversalPlan } from '../protocol/index.js';
2
+ import type { UniversalUsage, UniversalPlan } from '../protocol/index.js';
3
3
  export declare class ClaudeDriver implements AgentDriver {
4
4
  private readonly bin;
5
5
  readonly id = "claude";
@@ -14,6 +14,17 @@ export declare class ClaudeDriver implements AgentDriver {
14
14
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
15
15
  private usage;
16
16
  }
17
+ export interface ClaudeUsageState {
18
+ input: number | null;
19
+ output: number | null;
20
+ cached: number | null;
21
+ cacheCreation?: number | null;
22
+ contextWindow?: number | null;
23
+ turnOutputTokensBase?: number | null;
24
+ }
25
+ export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
26
+ export declare function claudeContextWindowFromModel(model: unknown): number | null;
27
+ export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
17
28
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
18
29
  export declare function claudeUserMessage(text: string): string;
19
30
  export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
@@ -46,6 +46,8 @@ export class ClaudeDriver {
46
46
  sessionId: null, model: null,
47
47
  stopReason: null, error: null,
48
48
  input: null, output: null, cached: null,
49
+ cacheCreation: null,
50
+ contextWindow: null, turnOutputTokensBase: 0,
49
51
  subAgents: new Map(),
50
52
  tools: new Map(),
51
53
  };
@@ -141,9 +143,45 @@ export class ClaudeDriver {
141
143
  });
142
144
  }
143
145
  usage(s) {
144
- return { inputTokens: s.input, outputTokens: s.output, cachedInputTokens: s.cached, contextPercent: null };
146
+ return claudeUsageOf(s);
145
147
  }
146
148
  }
149
+ export function claudeUsageOf(s) {
150
+ const used = (s.input ?? 0) + (s.cached ?? 0) + (s.cacheCreation ?? 0) + (s.output ?? 0);
151
+ const window = s.contextWindow ?? null;
152
+ const contextPercent = window && used > 0 ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
153
+ const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.output ?? 0);
154
+ return {
155
+ inputTokens: s.input,
156
+ outputTokens: s.output,
157
+ cachedInputTokens: s.cached,
158
+ contextUsedTokens: used > 0 ? used : null,
159
+ contextPercent,
160
+ turnOutputTokens: turnOutput > 0 ? turnOutput : null,
161
+ };
162
+ }
163
+ // Advertised context window by Claude model id (best-effort; unknown -> null so the
164
+ // percent simply stays absent rather than wrong). Anchor-free so vendor-prefixed ids
165
+ // (us.anthropic.claude-…) still match.
166
+ export function claudeContextWindowFromModel(model) {
167
+ const id = String(model ?? '').trim().toLowerCase();
168
+ if (!id)
169
+ return null;
170
+ if (id === 'haiku' || /claude-haiku-/.test(id))
171
+ return 200_000;
172
+ if (id === 'opus' || id === 'sonnet' || id === 'fable')
173
+ return 1_000_000;
174
+ if (/claude-(opus|sonnet)-/.test(id) || /claude-fable-/.test(id))
175
+ return 1_000_000;
176
+ return null;
177
+ }
178
+ // Usable window = advertised minus Claude's max-output (20k) + autocompact (13k) reserve.
179
+ const CLAUDE_USABLE_WINDOW_RESERVE = 33_000;
180
+ export function claudeEffectiveContextWindow(advertised) {
181
+ if (advertised == null)
182
+ return null;
183
+ return advertised <= CLAUDE_USABLE_WINDOW_RESERVE ? advertised : advertised - CLAUDE_USABLE_WINDOW_RESERVE;
184
+ }
147
185
  // Parse one claude stream-json event into kernel DriverEvents (pure + exported for
148
186
  // hermetic testing). Faithful to pikiloom's claudeParse shapes.
149
187
  export function handleClaudeEvent(ev, s, emit) {
@@ -178,16 +216,21 @@ export function handleClaudeEvent(ev, s, emit) {
178
216
  emit({ type: 'session', sessionId: ev.session_id });
179
217
  }
180
218
  s.model = ev.model ?? s.model;
219
+ s.contextWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(s.model)) ?? s.contextWindow;
181
220
  return;
182
221
  }
183
222
  if (t === 'stream_event') {
184
223
  const inner = ev.event || {};
185
224
  if (inner.type === 'message_start') {
186
225
  const u = inner.message?.usage;
187
- if (u) {
188
- s.input = u.input_tokens ?? s.input;
189
- s.cached = u.cache_read_input_tokens ?? s.cached;
190
- }
226
+ // Claude emits one message per tool-use round within a single turn. Carry the prior
227
+ // message's output into the per-turn base, then reset to the new message's prompt size
228
+ // so contextUsedTokens tracks current occupancy while turnOutputTokens sums the turn.
229
+ s.turnOutputTokensBase = (s.turnOutputTokensBase ?? 0) + (s.output ?? 0);
230
+ s.input = u?.input_tokens ?? 0;
231
+ s.cached = u?.cache_read_input_tokens ?? 0;
232
+ s.cacheCreation = u?.cache_creation_input_tokens ?? 0;
233
+ s.output = 0;
191
234
  }
192
235
  else if (inner.type === 'content_block_delta') {
193
236
  const d = inner.delta || {};
@@ -211,7 +254,9 @@ export function handleClaudeEvent(ev, s, emit) {
211
254
  s.input = u.input_tokens;
212
255
  if (u.cache_read_input_tokens != null)
213
256
  s.cached = u.cache_read_input_tokens;
214
- emit({ type: 'usage', usage: { inputTokens: s.input, outputTokens: s.output, cachedInputTokens: s.cached, contextPercent: null } });
257
+ if (u.cache_creation_input_tokens != null)
258
+ s.cacheCreation = u.cache_creation_input_tokens;
259
+ emit({ type: 'usage', usage: claudeUsageOf(s) });
215
260
  }
216
261
  if (inner.delta?.stop_reason)
217
262
  s.stopReason = inner.delta.stop_reason;
@@ -312,6 +357,8 @@ export function handleClaudeEvent(ev, s, emit) {
312
357
  const cached = u.cache_read_input_tokens ?? u.cached_input_tokens;
313
358
  if (s.cached == null && cached != null)
314
359
  s.cached = cached;
360
+ if (s.cacheCreation == null && u.cache_creation_input_tokens != null)
361
+ s.cacheCreation = u.cache_creation_input_tokens;
315
362
  }
316
363
  return;
317
364
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, TuiInput, TuiSpec } from '../contracts/driver.js';
2
+ import type { UniversalUsage } from '../protocol/index.js';
2
3
  export declare class CodexDriver implements AgentDriver {
3
4
  private readonly bin;
4
5
  readonly id = "codex";
@@ -12,3 +13,12 @@ export declare class CodexDriver implements AgentDriver {
12
13
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
13
14
  tui(input: TuiInput): TuiSpec;
14
15
  }
16
+ export interface CodexUsageState {
17
+ input: number | null;
18
+ output: number | null;
19
+ cached: number | null;
20
+ contextUsed?: number | null;
21
+ contextWindow?: number | null;
22
+ }
23
+ export declare function applyCodexTokenUsage(s: CodexUsageState, rawUsage: any): void;
24
+ export declare function codexUsageOf(s: CodexUsageState): UniversalUsage;
@@ -173,7 +173,7 @@ export class CodexDriver {
173
173
  // to the native account.
174
174
  const config = [...(input.configOverrides || [])];
175
175
  const srv = new AppServer(this.bin, config, input.env);
176
- const state = { text: '', reasoning: '', sessionId: input.sessionId ?? null, input: null, output: null, cached: null, status: null, error: null, turnId: null };
176
+ const state = { text: '', reasoning: '', sessionId: input.sessionId ?? null, input: null, output: null, cached: null, contextUsed: null, contextWindow: null, status: null, error: null, turnId: null };
177
177
  const phases = new Map();
178
178
  const toolSummaries = new Map();
179
179
  let steerRegistered = false;
@@ -264,14 +264,8 @@ export class CodexDriver {
264
264
  break;
265
265
  }
266
266
  case 'thread/tokenUsage/updated': {
267
- const u = params?.tokenUsage || params?.usage || {};
268
- if (u.input_tokens != null || u.inputTokens != null)
269
- state.input = u.input_tokens ?? u.inputTokens;
270
- if (u.output_tokens != null || u.outputTokens != null)
271
- state.output = u.output_tokens ?? u.outputTokens;
272
- if (u.cached_input_tokens != null || u.cachedInputTokens != null)
273
- state.cached = u.cached_input_tokens ?? u.cachedInputTokens;
274
- ctx.emit({ type: 'usage', usage: { inputTokens: state.input, outputTokens: state.output, cachedInputTokens: state.cached, contextPercent: null } });
267
+ applyCodexTokenUsage(state, params?.tokenUsage || params?.usage);
268
+ ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
275
269
  break;
276
270
  }
277
271
  case 'turn/completed': {
@@ -279,6 +273,8 @@ export class CodexDriver {
279
273
  state.status = turn.status ?? 'completed';
280
274
  if (turn.error)
281
275
  state.error = turn.error.message || turn.error.code || 'turn error';
276
+ applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
277
+ ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
282
278
  settle();
283
279
  break;
284
280
  }
@@ -309,7 +305,7 @@ export class CodexDriver {
309
305
  if (turnResp.error)
310
306
  return { ok: false, text: state.text, error: turnResp.error.message || 'turn/start failed', stopReason: 'error', sessionId: state.sessionId };
311
307
  await turnDone;
312
- const usage = { inputTokens: state.input, outputTokens: state.output, cachedInputTokens: state.cached, contextPercent: null };
308
+ const usage = codexUsageOf(state);
313
309
  const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
314
310
  return {
315
311
  ok: ok2,
@@ -344,3 +340,76 @@ function buildTurnInput(prompt, attachments) {
344
340
  input.push({ type: 'text', text: prompt });
345
341
  return input;
346
342
  }
343
+ function codexNum(...vals) {
344
+ for (const v of vals) {
345
+ const n = typeof v === 'number' ? v : Number(v);
346
+ if (Number.isFinite(n))
347
+ return n;
348
+ }
349
+ return null;
350
+ }
351
+ // Context occupancy from one usage record: prefer total_tokens, else input(+output).
352
+ function codexContextUsed(raw) {
353
+ if (!raw || typeof raw !== 'object')
354
+ return null;
355
+ const total = codexNum(raw.total_tokens, raw.totalTokens);
356
+ if (total != null && total >= 0)
357
+ return total;
358
+ const i = codexNum(raw.input_tokens, raw.inputTokens);
359
+ const o = codexNum(raw.output_tokens, raw.outputTokens);
360
+ if (i != null && o != null)
361
+ return i + o;
362
+ return i;
363
+ }
364
+ // Fold a codex tokenUsage payload into driver state: last-turn counts, context
365
+ // occupancy, and the model window. Tolerant of nested {info:{…}} and flat shapes.
366
+ export function applyCodexTokenUsage(s, rawUsage) {
367
+ if (!rawUsage || typeof rawUsage !== 'object')
368
+ return;
369
+ const info = rawUsage.info && typeof rawUsage.info === 'object' ? rawUsage.info : rawUsage;
370
+ const last = info.last ?? info.lastTokenUsage ?? info.last_token_usage ?? rawUsage.last;
371
+ const li = codexNum(last?.input_tokens, last?.inputTokens);
372
+ const lo = codexNum(last?.output_tokens, last?.outputTokens);
373
+ const lc = codexNum(last?.cached_input_tokens, last?.cachedInputTokens);
374
+ if (li != null)
375
+ s.input = li;
376
+ if (lo != null)
377
+ s.output = lo;
378
+ if (lc != null)
379
+ s.cached = lc;
380
+ const used = codexContextUsed(last);
381
+ if (used != null)
382
+ s.contextUsed = used;
383
+ // Cumulative total as fallback for the per-turn counts (the trailing `?? rawUsage`
384
+ // also catches the flat shape, where there is no nested `last`).
385
+ const total = info.total ?? info.totalTokenUsage ?? info.total_token_usage ?? rawUsage.total ?? rawUsage;
386
+ if (total && typeof total === 'object') {
387
+ const ti = codexNum(total.input_tokens, total.inputTokens);
388
+ const to = codexNum(total.output_tokens, total.outputTokens);
389
+ const tc = codexNum(total.cached_input_tokens, total.cachedInputTokens);
390
+ if (li == null && ti != null)
391
+ s.input = ti;
392
+ if (lo == null && to != null)
393
+ s.output = to;
394
+ if (lc == null && tc != null)
395
+ s.cached = tc;
396
+ }
397
+ const cw = codexNum(info.modelContextWindow, info.model_context_window, rawUsage.modelContextWindow, rawUsage.model_context_window);
398
+ if (cw != null && cw > 0)
399
+ s.contextWindow = cw;
400
+ }
401
+ export function codexUsageOf(s) {
402
+ const fallback = (s.input ?? 0) + (s.cached ?? 0);
403
+ const used = s.contextUsed ?? (fallback > 0 ? fallback : null);
404
+ const window = s.contextWindow ?? null;
405
+ const contextPercent = used != null && window ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
406
+ const turnOutput = s.output ?? 0;
407
+ return {
408
+ inputTokens: s.input,
409
+ outputTokens: s.output,
410
+ cachedInputTokens: s.cached,
411
+ contextUsedTokens: used,
412
+ contextPercent,
413
+ turnOutputTokens: turnOutput > 0 ? turnOutput : null,
414
+ };
415
+ }
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.4",
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",