pikiloom 0.4.38 → 0.4.40

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 (36) hide show
  1. package/dashboard/dist/assets/{AgentTab-DbFzaIyZ.js → AgentTab-Cj7sekcS.js} +1 -1
  2. package/dashboard/dist/assets/{ConnectionModal-QNYOWHCU.js → ConnectionModal-g_6IHAWE.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BGaKHjFT.js → DirBrowser-DVe6q12N.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-D4Gv9b8z.js → ExtensionsTab-GqxD61Pp.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-DxxoZd84.js → IMAccessTab-DIqTzZJ9.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Nm2e93s5.js → Modal-CIYrMwm0.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BwxZmU0U.js → Modals-B-Afu2rD.js} +1 -1
  8. package/dashboard/dist/assets/{Select-SGlII0yx.js → Select-B1UL27gw.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-BHuG5hxj.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-BF6lIAYM.js → SystemTab-Bv1uT5-h.js} +1 -1
  11. package/dashboard/dist/assets/{index-DZiAiRNt.js → index-BOEW0RIx.js} +14 -14
  12. package/dashboard/dist/assets/index-Bthwt6K_.css +1 -0
  13. package/dashboard/dist/assets/{index-BP8R_bLT.js → index-qKexKdJl.js} +3 -3
  14. package/dashboard/dist/assets/{shared-S0kcs5yP.js → shared-624PaUfm.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/drivers/codex.js +5 -3
  17. package/dist/agent/index.js +1 -1
  18. package/dist/agent/kernel-bridge.js +1 -0
  19. package/dist/agent/mcp/capabilities.js +39 -0
  20. package/dist/agent/stream.js +13 -1
  21. package/dist/bot/bot.js +4 -13
  22. package/dist/dashboard/routes/agents.js +8 -4
  23. package/package.json +1 -1
  24. package/packages/kernel/README.md +263 -65
  25. package/packages/kernel/dist/contracts/surface.d.ts +17 -0
  26. package/packages/kernel/dist/drivers/claude.d.ts +12 -1
  27. package/packages/kernel/dist/drivers/claude.js +69 -5
  28. package/packages/kernel/dist/drivers/codex.d.ts +28 -1
  29. package/packages/kernel/dist/drivers/codex.js +161 -16
  30. package/packages/kernel/dist/index.d.ts +1 -1
  31. package/packages/kernel/dist/runtime/hub.d.ts +3 -0
  32. package/packages/kernel/dist/runtime/hub.js +84 -10
  33. package/packages/kernel/dist/runtime/loom.d.ts +1 -0
  34. package/packages/kernel/dist/runtime/loom.js +4 -1
  35. package/dashboard/dist/assets/SessionPanel-DYfSlreh.js +0 -1
  36. package/dashboard/dist/assets/index-CtS48Jn-.css +0 -1
@@ -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,15 +216,37 @@ 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;
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;
234
+ }
235
+ else if (inner.type === 'content_block_start') {
236
+ // Claude emits multiple text/thinking blocks per turn (one set per tool-use round). Insert a
237
+ // paragraph break before a NEW block when prior content exists, so the live preview shows
238
+ // breaks between segments instead of running them together. Mirrors the legacy driver; the
239
+ // separator is emitted as a delta so the runtime's accumulated snapshot stays in sync with s.
240
+ const bt = inner.content_block?.type;
241
+ if (bt === 'text' && s.text && !s.text.endsWith('\n\n')) {
242
+ const sep = s.text.endsWith('\n') ? '\n' : '\n\n';
243
+ s.text += sep;
244
+ emit({ type: 'text', delta: sep });
245
+ }
246
+ else if (bt === 'thinking' && s.reasoning && !s.reasoning.endsWith('\n\n')) {
247
+ const sep = s.reasoning.endsWith('\n') ? '\n' : '\n\n';
248
+ s.reasoning += sep;
249
+ emit({ type: 'reasoning', delta: sep });
190
250
  }
191
251
  }
192
252
  else if (inner.type === 'content_block_delta') {
@@ -211,7 +271,9 @@ export function handleClaudeEvent(ev, s, emit) {
211
271
  s.input = u.input_tokens;
212
272
  if (u.cache_read_input_tokens != null)
213
273
  s.cached = u.cache_read_input_tokens;
214
- emit({ type: 'usage', usage: { inputTokens: s.input, outputTokens: s.output, cachedInputTokens: s.cached, contextPercent: null } });
274
+ if (u.cache_creation_input_tokens != null)
275
+ s.cacheCreation = u.cache_creation_input_tokens;
276
+ emit({ type: 'usage', usage: claudeUsageOf(s) });
215
277
  }
216
278
  if (inner.delta?.stop_reason)
217
279
  s.stopReason = inner.delta.stop_reason;
@@ -312,6 +374,8 @@ export function handleClaudeEvent(ev, s, emit) {
312
374
  const cached = u.cache_read_input_tokens ?? u.cached_input_tokens;
313
375
  if (s.cached == null && cached != null)
314
376
  s.cached = cached;
377
+ if (s.cacheCreation == null && u.cache_creation_input_tokens != null)
378
+ s.cacheCreation = u.cache_creation_input_tokens;
315
379
  }
316
380
  return;
317
381
  }
@@ -1,4 +1,22 @@
1
- import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, TuiInput, TuiSpec } from '../contracts/driver.js';
1
+ import type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, TuiInput, TuiSpec } from '../contracts/driver.js';
2
+ import type { UniversalUsage } from '../protocol/index.js';
3
+ export declare function codexToolSummary(item: any): {
4
+ id: string;
5
+ name: string;
6
+ summary: string;
7
+ } | null;
8
+ export interface CodexContentState {
9
+ text: string;
10
+ reasoning: string;
11
+ streamedReasoning: boolean;
12
+ msgs: string[];
13
+ thinkParts: string[];
14
+ }
15
+ export declare function codexReasoningItemText(item: any): string;
16
+ export declare function captureCodexAgentMessage(item: any, s: CodexContentState, deltaItems: Set<string>, phases: Map<string, string>, emit: (e: DriverEvent) => void): void;
17
+ export declare function captureCodexReasoning(text: string, s: CodexContentState, emit: (e: DriverEvent) => void): void;
18
+ export declare function codexFinalText(s: CodexContentState): string;
19
+ export declare function codexFinalReasoning(s: CodexContentState): string;
2
20
  export declare class CodexDriver implements AgentDriver {
3
21
  private readonly bin;
4
22
  readonly id = "codex";
@@ -12,3 +30,12 @@ export declare class CodexDriver implements AgentDriver {
12
30
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
13
31
  tui(input: TuiInput): TuiSpec;
14
32
  }
33
+ export interface CodexUsageState {
34
+ input: number | null;
35
+ output: number | null;
36
+ cached: number | null;
37
+ contextUsed?: number | null;
38
+ contextWindow?: number | null;
39
+ }
40
+ export declare function applyCodexTokenUsage(s: CodexUsageState, rawUsage: any): void;
41
+ export declare function codexUsageOf(s: CodexUsageState): UniversalUsage;
@@ -20,7 +20,12 @@ class AppServer {
20
20
  onServerRequest(cb) { this.requestResponder = cb; }
21
21
  async start() {
22
22
  const args = ['app-server'];
23
- const overrides = this.configOverrides.some(c => /^features\.goals\s*=/.test(c)) ? this.configOverrides : [...this.configOverrides, 'features.goals=true'];
23
+ // Do NOT force model_reasoning_summary codex reasoning summaries stay OFF by default
24
+ // (respecting ~/.codex/config.toml, which is the original behavior). A caller that wants
25
+ // thinking can pass `model_reasoning_summary=...` via configOverrides; we never inject it.
26
+ const overrides = [...this.configOverrides];
27
+ if (!overrides.some(c => /^features\.goals\s*=/.test(c)))
28
+ overrides.push('features.goals=true');
24
29
  for (const c of overrides)
25
30
  args.push('-c', c);
26
31
  try {
@@ -126,7 +131,12 @@ function codexFileChangeSummary(item) {
126
131
  }
127
132
  // Normalize a codex app-server item -> {id, name, summary}. The summary mirrors pikiloom's
128
133
  // vocabulary ("Run shell: <cmd>", "Edit <path>") so the activity projection reads naturally.
129
- function codexToolSummary(item) {
134
+ // Only ACTUAL tool calls become Activity rows. agentMessage (the answer text) and reasoning
135
+ // (thinking) are CONTENT — they render below as message text / thinking, never as tools. The old
136
+ // fallback to `item.type` wrongly turned every item (incl. agentMessage/reasoning) into a bogus
137
+ // "tool" in the Activity card. Mirrors the legacy driver's isCodexToolCallItem whitelist.
138
+ const CODEX_TOOL_CALL_TYPES = new Set(['dynamicToolCall', 'mcpToolCall', 'collabAgentToolCall']);
139
+ export function codexToolSummary(item) {
130
140
  const id = String(item?.id || '');
131
141
  if (!id)
132
142
  return null;
@@ -136,8 +146,13 @@ function codexToolSummary(item) {
136
146
  }
137
147
  if (item.type === 'fileChange' || item.type === 'patch')
138
148
  return { id, name: 'edit', summary: codexFileChangeSummary(item) };
139
- const tool = typeof item?.tool === 'string' ? item.tool : typeof item?.name === 'string' ? item.name : item?.type;
140
- return tool ? { id, name: String(tool), summary: String(tool) } : null;
149
+ if (CODEX_TOOL_CALL_TYPES.has(item.type)) {
150
+ const raw = typeof item.tool === 'string' && item.tool.trim() ? item.tool.trim()
151
+ : typeof item.name === 'string' && item.name.trim() ? item.name.trim() : '';
152
+ const name = raw ? (raw.split('.').pop() || raw) : 'tool';
153
+ return { id, name, summary: raw ? `Use ${name}` : 'Use tool' };
154
+ }
155
+ return null;
141
156
  }
142
157
  // codex `item/tool/requestUserInput` params -> a normalized UniversalInteraction
143
158
  // (faithful to the legacy codex driver's toAgentInteraction shape).
@@ -160,6 +175,51 @@ function codexUserInputToInteraction(params, promptId) {
160
175
  return null;
161
176
  return { promptId, kind: 'user-input', title: 'User Input Required', hint: 'Use the buttons when available, or reply with text.', questions };
162
177
  }
178
+ // Reasoning text from a completed `reasoning` item (item/completed or rawResponseItem/completed):
179
+ // summary/content arrays of plain strings or {text} objects.
180
+ export function codexReasoningItemText(item) {
181
+ const parts = [
182
+ ...(Array.isArray(item?.summary) ? item.summary : []),
183
+ ...(Array.isArray(item?.content) ? item.content : []),
184
+ ];
185
+ return parts.map((p) => (typeof p === 'string' ? p : p?.text || '')).filter(Boolean).join('\n').trim();
186
+ }
187
+ // A completed final_answer agentMessage that did NOT stream deltas: append + emit it live.
188
+ // deltaItems holds the ids already streamed, so a completed item echoing a streamed one is
189
+ // not double-counted (matches the legacy driver's deltaSeenForItem guard).
190
+ export function captureCodexAgentMessage(item, s, deltaItems, phases, emit) {
191
+ const phase = item?.phase || (item?.id ? phases.get(item.id) : null) || 'final_answer';
192
+ if (phase !== 'final_answer')
193
+ return;
194
+ const text = typeof item?.text === 'string' ? item.text.trim() : '';
195
+ if (!text)
196
+ return;
197
+ s.msgs.push(text);
198
+ if (item.id && deltaItems.has(item.id))
199
+ return;
200
+ const delta = s.text.trim() ? `\n\n${text}` : text;
201
+ s.text += delta;
202
+ emit({ type: 'text', delta });
203
+ }
204
+ // A completed reasoning item: keep it for the end-of-turn fallback, and stream it live only
205
+ // when nothing arrived as reasoning deltas (so the streamed path is never double-emitted).
206
+ export function captureCodexReasoning(text, s, emit) {
207
+ if (!text)
208
+ return;
209
+ s.thinkParts.push(text);
210
+ if (s.streamedReasoning)
211
+ return;
212
+ const delta = s.reasoning.trim() ? `\n\n${text}` : text;
213
+ s.reasoning += delta;
214
+ emit({ type: 'reasoning', delta });
215
+ }
216
+ // End-of-turn finalizers: prefer streamed text/reasoning, fall back to completed-item parts.
217
+ export function codexFinalText(s) {
218
+ return s.text.trim() ? s.text : s.msgs.join('\n\n');
219
+ }
220
+ export function codexFinalReasoning(s) {
221
+ return s.reasoning.trim() ? s.reasoning : s.thinkParts.join('\n\n');
222
+ }
163
223
  export class CodexDriver {
164
224
  bin;
165
225
  id = 'codex';
@@ -173,9 +233,10 @@ export class CodexDriver {
173
233
  // to the native account.
174
234
  const config = [...(input.configOverrides || [])];
175
235
  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 };
236
+ const state = { text: '', reasoning: '', streamedReasoning: false, msgs: [], thinkParts: [], sessionId: input.sessionId ?? null, input: null, output: null, cached: null, contextUsed: null, contextWindow: null, status: null, error: null, turnId: null };
177
237
  const phases = new Map();
178
238
  const toolSummaries = new Map();
239
+ const deltaItems = new Set();
179
240
  let steerRegistered = false;
180
241
  const ok = await srv.start();
181
242
  if (!ok)
@@ -239,6 +300,8 @@ export class CodexDriver {
239
300
  const phase = params?.itemId ? (phases.get(params.itemId) || 'final_answer') : 'final_answer';
240
301
  if (phase === 'final_answer' && params?.delta) {
241
302
  state.text += params.delta;
303
+ if (params.itemId)
304
+ deltaItems.add(params.itemId);
242
305
  ctx.emit({ type: 'text', delta: params.delta });
243
306
  }
244
307
  break;
@@ -247,16 +310,28 @@ export class CodexDriver {
247
310
  case 'item/reasoning/summaryTextDelta':
248
311
  if (params?.delta) {
249
312
  state.reasoning += params.delta;
313
+ state.streamedReasoning = true;
250
314
  ctx.emit({ type: 'reasoning', delta: params.delta });
251
315
  }
252
316
  break;
253
317
  case 'item/completed': {
254
318
  const item = params?.item || {};
319
+ // Final answer / reasoning delivered as a completed item (no preceding deltas).
320
+ if (item.type === 'agentMessage')
321
+ captureCodexAgentMessage(item, state, deltaItems, phases, ctx.emit);
322
+ else if (item.type === 'reasoning')
323
+ captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
255
324
  const t = codexToolSummary(item);
256
325
  if (t && toolSummaries.has(t.id))
257
326
  ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary: toolSummaries.get(t.id) || t.summary, status: item.status === 'failed' ? 'failed' : 'done' } });
258
327
  break;
259
328
  }
329
+ case 'rawResponseItem/completed': {
330
+ const item = params?.item || {};
331
+ if (item?.type === 'reasoning')
332
+ captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
333
+ break;
334
+ }
260
335
  case 'turn/plan/updated': {
261
336
  const plan = planFromUpdate(params);
262
337
  if (plan)
@@ -264,14 +339,8 @@ export class CodexDriver {
264
339
  break;
265
340
  }
266
341
  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 } });
342
+ applyCodexTokenUsage(state, params?.tokenUsage || params?.usage);
343
+ ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
275
344
  break;
276
345
  }
277
346
  case 'turn/completed': {
@@ -279,6 +348,8 @@ export class CodexDriver {
279
348
  state.status = turn.status ?? 'completed';
280
349
  if (turn.error)
281
350
  state.error = turn.error.message || turn.error.code || 'turn error';
351
+ applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
352
+ ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
282
353
  settle();
283
354
  break;
284
355
  }
@@ -309,12 +380,13 @@ export class CodexDriver {
309
380
  if (turnResp.error)
310
381
  return { ok: false, text: state.text, error: turnResp.error.message || 'turn/start failed', stopReason: 'error', sessionId: state.sessionId };
311
382
  await turnDone;
312
- const usage = { inputTokens: state.input, outputTokens: state.output, cachedInputTokens: state.cached, contextPercent: null };
383
+ const usage = codexUsageOf(state);
313
384
  const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
385
+ const finalReasoning = codexFinalReasoning(state);
314
386
  return {
315
387
  ok: ok2,
316
- text: state.text,
317
- reasoning: state.reasoning || undefined,
388
+ text: codexFinalText(state),
389
+ reasoning: finalReasoning || undefined,
318
390
  error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
319
391
  stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
320
392
  sessionId: state.sessionId,
@@ -344,3 +416,76 @@ function buildTurnInput(prompt, attachments) {
344
416
  input.push({ type: 'text', text: prompt });
345
417
  return input;
346
418
  }
419
+ function codexNum(...vals) {
420
+ for (const v of vals) {
421
+ const n = typeof v === 'number' ? v : Number(v);
422
+ if (Number.isFinite(n))
423
+ return n;
424
+ }
425
+ return null;
426
+ }
427
+ // Context occupancy from one usage record: prefer total_tokens, else input(+output).
428
+ function codexContextUsed(raw) {
429
+ if (!raw || typeof raw !== 'object')
430
+ return null;
431
+ const total = codexNum(raw.total_tokens, raw.totalTokens);
432
+ if (total != null && total >= 0)
433
+ return total;
434
+ const i = codexNum(raw.input_tokens, raw.inputTokens);
435
+ const o = codexNum(raw.output_tokens, raw.outputTokens);
436
+ if (i != null && o != null)
437
+ return i + o;
438
+ return i;
439
+ }
440
+ // Fold a codex tokenUsage payload into driver state: last-turn counts, context
441
+ // occupancy, and the model window. Tolerant of nested {info:{…}} and flat shapes.
442
+ export function applyCodexTokenUsage(s, rawUsage) {
443
+ if (!rawUsage || typeof rawUsage !== 'object')
444
+ return;
445
+ const info = rawUsage.info && typeof rawUsage.info === 'object' ? rawUsage.info : rawUsage;
446
+ const last = info.last ?? info.lastTokenUsage ?? info.last_token_usage ?? rawUsage.last;
447
+ const li = codexNum(last?.input_tokens, last?.inputTokens);
448
+ const lo = codexNum(last?.output_tokens, last?.outputTokens);
449
+ const lc = codexNum(last?.cached_input_tokens, last?.cachedInputTokens);
450
+ if (li != null)
451
+ s.input = li;
452
+ if (lo != null)
453
+ s.output = lo;
454
+ if (lc != null)
455
+ s.cached = lc;
456
+ const used = codexContextUsed(last);
457
+ if (used != null)
458
+ s.contextUsed = used;
459
+ // Cumulative total as fallback for the per-turn counts (the trailing `?? rawUsage`
460
+ // also catches the flat shape, where there is no nested `last`).
461
+ const total = info.total ?? info.totalTokenUsage ?? info.total_token_usage ?? rawUsage.total ?? rawUsage;
462
+ if (total && typeof total === 'object') {
463
+ const ti = codexNum(total.input_tokens, total.inputTokens);
464
+ const to = codexNum(total.output_tokens, total.outputTokens);
465
+ const tc = codexNum(total.cached_input_tokens, total.cachedInputTokens);
466
+ if (li == null && ti != null)
467
+ s.input = ti;
468
+ if (lo == null && to != null)
469
+ s.output = to;
470
+ if (lc == null && tc != null)
471
+ s.cached = tc;
472
+ }
473
+ const cw = codexNum(info.modelContextWindow, info.model_context_window, rawUsage.modelContextWindow, rawUsage.model_context_window);
474
+ if (cw != null && cw > 0)
475
+ s.contextWindow = cw;
476
+ }
477
+ export function codexUsageOf(s) {
478
+ const fallback = (s.input ?? 0) + (s.cached ?? 0);
479
+ const used = s.contextUsed ?? (fallback > 0 ? fallback : null);
480
+ const window = s.contextWindow ?? null;
481
+ const contextPercent = used != null && window ? Math.min(99.9, Math.round((used / window) * 1000) / 10) : null;
482
+ const turnOutput = s.output ?? 0;
483
+ return {
484
+ inputTokens: s.input,
485
+ outputTokens: s.output,
486
+ cachedInputTokens: s.cached,
487
+ contextUsedTokens: used,
488
+ contextPercent,
489
+ turnOutputTokens: turnOutput > 0 ? turnOutput : null,
490
+ };
491
+ }
@@ -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;