@pikiloom/kernel 0.1.1 → 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
@@ -1,107 +1,305 @@
1
1
  # @pikiloom/kernel
2
2
 
3
- Heterogeneous coding agents (Claude / Codex / Gemini / ACP) an **interaction-friendly,
4
- accumulating session snapshot + control handle**, exposed over **pluggable surfaces**
5
- (IM, Web, tunnel). Environmental concerns are **injected ports** with working defaults.
3
+ Turn heterogeneous coding agents (**Claude Code · Codex · Gemini · ACP/Hermes**) into one
4
+ **uniform, accumulating session snapshot + a small set of control verbs**, exposed over
5
+ **pluggable surfaces** (IM, Web, tunnel, raw terminal). Environmental concerns (storage,
6
+ credentials, tools, prompts) are **injected ports** with working defaults.
6
7
 
7
- This is the reusable core **pikiloom itself is meant to be built on**. Drop it into any
8
- project and stand up a "pikiloom-like" backend in a few lines.
8
+ This is the reusable core that [pikiloom](https://github.com/xiaotonng/pikiloom) itself is
9
+ built on. Drop it into any project and stand up a "pikiloom-like" backend in a few lines
10
+ **you never parse a CLI's output or learn each agent's wire format**; you read one
11
+ `UniversalSnapshot` and call `prompt / stop / steer / interact`.
9
12
 
10
- ## The model (one package, three rings)
13
+ ```bash
14
+ npm i @pikiloom/kernel
15
+ ```
16
+
17
+ - Runtime dep: `ws`. Optional: `node-pty` (only for raw-TUI passthrough). Node ≥ 20, ESM-only.
18
+ - TypeScript types ship in the package (`dist/**/*.d.ts`). For an LLM-oriented summary, see [`llms.txt`](./llms.txt).
19
+
20
+ ---
21
+
22
+ ## Mental model
11
23
 
12
24
  ```
13
- 上层 (you write): IM bindings · Web UI/skin · Plugins ── implement Surface / Plugin
25
+ 上层 (you write): IM bindings · Web UI · Plugins ── implement Surface / Plugin
14
26
  ▲ createLoom({ surfaces, plugins, ...ports })
15
- @pikiloom/kernel: terminal/ Surface contract + LoomIO + built-in WebSurface
16
- (one import, runtime/ SessionRunner · Hub (multi-session) · snapshot accumulation · control verbs
17
- internally agent/ driver registry + Claude/Echo drivers + spawn/parse Driver
18
- modular) protocol/ UniversalSnapshot + diff (the wire vocabulary) Channel
19
- ports/ SessionStore · ModelResolver · ToolProvider · ... (+ defaults)
20
- spawns external CLIs (unchanged)
21
- 下层 (unchanged): the `claude` / `codex` / binaries, native protocols
27
+ @pikiloom/kernel: contracts/ Driver · Surface · LoomIO · Ports
28
+ (one import, runtime/ SessionRunner · Hub (multi-session) · snapshot accumulation · control verbs
29
+ modular inside) drivers/ Claude / Codex / Gemini / Hermes / Echo the agent axis (下层)
30
+ surfaces/ WebSurface (ws host) · CliSurface
31
+ protocol/ UniversalSnapshot + diff ← the wire vocabulary
32
+ ports/ SessionStore · ModelResolver · ToolProvider · ... (+ defaults)
33
+ spawns the external `claude` / `codex` / `gemini` CLIs (unchanged)
34
+ 下层 (unchanged): the agent binaries, native protocols
22
35
  ```
23
36
 
24
- **IM and Web are not two systems** — they are both just `Surface`s over the same `LoomIO`.
37
+ **IM and Web are not two systems** — both are just `Surface`s over the same `LoomIO`.
38
+
39
+ ### Two rails, same driver registry
40
+
41
+ | Rail | Driver method | Output | Use for |
42
+ |------|---------------|--------|---------|
43
+ | **Structured** | `driver.run(input, ctx)` | streamed `UniversalSnapshot` (text, reasoning, tool activity, plan, usage) | IM, Web dashboards, any UI that renders structured turns |
44
+ | **Raw PTY** | `driver.tui(input)` | a real full-screen interactive process passed through a PTY | a local terminal app (`pikiloom code`-style); needs `node-pty` |
25
45
 
26
- ## Quickstart
46
+ ---
47
+
48
+ ## Quick start
49
+
50
+ ### 1. One turn through one agent — `runTurn` (the bridge primitive)
51
+
52
+ The smallest unit. No persistence, no multi-session — just run a turn and get a streamed
53
+ snapshot + final result. This is exactly what an existing app maps onto its own UI.
27
54
 
28
55
  ```ts
29
- import { createLoom, ClaudeDriver, WebSurface } from '@pikiloom/kernel';
56
+ import { runTurn, ClaudeDriver } from '@pikiloom/kernel';
57
+
58
+ const { result, snapshot } = await runTurn(
59
+ new ClaudeDriver(),
60
+ { prompt: 'Summarize package.json in one line', workdir: process.cwd(), effort: 'high' },
61
+ {
62
+ onSnapshot: (s) => {
63
+ // fires on every event — render live:
64
+ // s.text accumulated assistant output
65
+ // s.reasoning accumulated thinking (when the model/auth exposes it)
66
+ // s.activity human-readable execution trail ("Read foo.ts", "Run shell: npm test")
67
+ // s.toolCalls structured tool calls [{ name, summary, status }]
68
+ // s.plan, s.usage, s.artifacts, s.interactions
69
+ process.stdout.write(`\r${s.activity?.split('\n').at(-1) ?? ''}`);
70
+ },
71
+ onSteer: (steer) => { /* call steer('extra prompt') mid-turn */ },
72
+ signal: undefined, // pass an AbortSignal to stop the turn
73
+ },
74
+ );
75
+ console.log(result.ok, result.text, result.sessionId);
76
+ ```
77
+
78
+ ### 2. A full multi-session backend — `createLoom`
79
+
80
+ Adds persistence, a session hub, per-session queueing, discovery, and surfaces. Every port
81
+ has a default, so this runs with zero wiring.
82
+
83
+ ```ts
84
+ import { createLoom, ClaudeDriver, CodexDriver, WebSurface } from '@pikiloom/kernel';
30
85
 
31
86
  const loom = createLoom({
32
- drivers: [new ClaudeDriver()], // 下层 (unchanged)
33
- surfaces: [new WebSurface({ port: 8787 })], // 上层 (IM / Web / tunnel)
87
+ drivers: [new ClaudeDriver(), new CodexDriver()], // 下层 (unchanged binaries)
88
+ surfaces: [new WebSurface({ port: 8787 })], // 上层 (Web/tunnel; add your IM Surface here)
89
+ defaultAgent: 'claude',
90
+ // optional ports — override any one to swap storage / credentials / tools / prompts:
91
+ // sessionStore, modelResolver, toolProvider, systemPromptBuilder, catalog, interactionHandler
34
92
  });
35
93
  await loom.start();
94
+
95
+ // Drive it from anywhere via loom.io (LoomIO):
96
+ const { sessionKey, taskId } = await loom.io.prompt({ prompt: 'hello', agent: 'claude' });
97
+ const unsub = loom.io.subscribe((key, snapshot, patch, seq) => { /* render */ });
98
+ loom.io.steer(taskId, 'actually, do X instead');
99
+ loom.io.stop(sessionKey);
36
100
  ```
37
101
 
38
- Add an IM terminal by implementing `Surface.start(io)`: route inbound messages to
39
- `io.prompt(...)` and render `io.subscribe(...)` snapshots back. Everything else (storage,
40
- credentials, tools, prompts) has a default and is overridable via ports.
102
+ To add an **IM channel**, implement `Surface.start(io)`: route inbound messages to
103
+ `io.prompt(...)` and render `io.subscribe(...)` snapshots back out. Nothing else changes.
104
+
105
+ ### 3. Raw TUI passthrough — `runTui` / `openTui`
106
+
107
+ ```ts
108
+ const loom = createLoom({ drivers: [new ClaudeDriver()], defaultAgent: 'claude' });
109
+ await loom.runTui({ agent: 'claude', workdir: process.cwd() }); // you're now in the real Claude TUI
110
+ ```
41
111
 
42
- ### TUI passthrough (`pikiloom code` Claude/Codex TUI)
112
+ `runTui` does full stdin/stdout raw passthrough on the current terminal. `openTui` returns a
113
+ `PtyBridge` (`onData` / `write` / `resize` / `onExit`) so you can drive or tee it yourself.
114
+ Requires the optional `node-pty` dependency (`ptyAvailable()` reports availability).
43
115
 
44
- Two orthogonal driver outputs off the same registry: `run()` yields a structured
45
- `UniversalSnapshot` (Web/IM); `tui()` yields a raw, full-screen interactive process that
46
- the kernel spawns in a **PTY** and passes through transparently (with optional tee/mirror).
116
+ ---
117
+
118
+ ## The data you read: `UniversalSnapshot`
119
+
120
+ One driver-agnostic shape for every agent. A surface renders *this* and never touches a
121
+ CLI's native format. It **accumulates** across a turn (text/reasoning append; structured
122
+ fields replace).
47
123
 
48
124
  ```ts
49
- import { createLoom, ClaudeDriver, CodexDriver } from '@pikiloom/kernel';
50
- const loom = createLoom({ drivers: [new ClaudeDriver(), new CodexDriver()], defaultAgent: 'claude' });
51
- await loom.runTui({ agent: 'claude', workdir: process.cwd() }); // you're now in the real Claude TUI, launched by the kernel
125
+ interface UniversalSnapshot {
126
+ phase: 'idle' | 'queued' | 'streaming' | 'done';
127
+ taskId?: string | null;
128
+ sessionId?: string | null;
129
+ agent?: string | null;
130
+ model?: string | null;
131
+ effort?: string | null;
132
+ prompt?: string | null;
133
+
134
+ text?: string; // accumulated assistant output
135
+ reasoning?: string; // accumulated thinking (only when the model/auth streams it; see note)
136
+ activity?: string; // human-readable execution trail, one line per tool/subagent (kernel-derived)
137
+ plan?: UniversalPlan | null; // { explanation, steps:[{ text, status }] }
138
+ toolCalls?: UniversalToolCall[]; // structured: { id, name, summary, input?, result?, status }
139
+ subAgents?: UniversalSubAgent[]; // spawned sub-agents and their tools
140
+ usage?: UniversalUsage | null; // { inputTokens, outputTokens, cachedInputTokens, contextPercent, ... }
141
+ artifacts?: UniversalArtifact[]; // generated files/images { url|path, fileName, mime, kind }
142
+ interactions?: UniversalInteraction[]; // pending human-in-the-loop questions (answer via interact())
143
+ queued?: UniversalQueuedTask[]; // prompts waiting behind the running turn
144
+
145
+ error?: string | null;
146
+ incomplete?: boolean; // true if the turn was interrupted / errored
147
+ startedAt?: number;
148
+ updatedAt: number;
149
+ }
52
150
  ```
53
151
 
54
- `runTui` does full stdin/stdout raw passthrough on the current terminal; `openTui` returns
55
- a `PtyBridge` (`onData`/`write`/`resize`/`onExit`) so you can drive or tee it yourself.
56
- Requires the optional `node-pty` dependency.
152
+ Sub-shapes:
57
153
 
58
- ## Contracts
154
+ ```ts
155
+ interface UniversalToolCall { id: string; name: string; summary: string; input?: string | null; result?: string | null; status: 'running' | 'done' | 'failed'; }
156
+ interface UniversalPlan { explanation: string | null; steps: { text: string; status: 'pending' | 'inProgress' | 'completed' }[]; }
157
+ interface UniversalUsage { inputTokens: number | null; outputTokens: number | null; cachedInputTokens: number | null; contextUsedTokens?: number | null; contextPercent: number | null; providerName?: string | null; }
158
+ interface UniversalArtifact { url?: string; path?: string; fileName: string; mime?: string; kind: 'photo' | 'document'; caption?: string; }
159
+ interface UniversalInteraction { promptId: string; kind: 'user-input' | 'permission' | 'confirmation'; title: string; questions: { id: string; text: string; type?: 'text' | 'select'; choices?: { label: string; value?: string }[] }[]; }
160
+ ```
59
161
 
60
- | Seam | Interface | Direction |
61
- |------|-----------|-----------|
62
- | Agent (下层) | `AgentDriver.run(input, ctx)` emits `DriverEvent`s | down, bundled (+ `registerDriver` escape hatch) |
63
- | Surface (上层) | `Surface.start(io: LoomIO)` | up, app-provided (WebSurface built-in) |
64
- | Session API | `LoomIO` = prompt/stop/steer/interact + subscribe/getSnapshot/listSessions | the meeting point |
65
- | Plugin | `Plugin.tools()` / `decorateSnapshot()` | up, app-provided |
66
- | Ports | `SessionStore` · `ModelResolver` · `ToolProvider` · `SystemPromptBuilder` · `InteractionHandler` | side, defaults included |
162
+ **Activity projection (kernel-owned).** Every driver emits *structured* tool calls; the
163
+ kernel's `SessionRunner` derives `snapshot.activity` from `toolCalls` + `subAgents` centrally
164
+ (`projectActivity`): one line per call with a status suffix `summary` while running,
165
+ `summary done` / `summary -> detail` on success, `summary failed: detail` on error. So every
166
+ surface gets a readable execution trail for free, and rich UIs can still use `toolCalls`
167
+ directly. You implement this **nowhere** it's a property of the snapshot.
168
+
169
+ **Reasoning note.** Plaintext thinking only appears when the agent actually streams it
170
+ (e.g. BYOK Anthropic API keys, Codex `item/reasoning`). Subscription/OAuth Claude withholds
171
+ plaintext extended-thinking (streams only an encrypted signature), so `reasoning` is empty
172
+ there — a platform behavior, not a kernel limitation. The Claude driver also captures
173
+ reasoning delivered as a complete block, not only as streamed deltas.
174
+
175
+ ### Streaming on the wire — `diffSnapshot` / `applySnapshotPatch`
176
+
177
+ `diffSnapshot(prev, next)` produces a compact `SnapshotPatch` (prefix-append for `text` /
178
+ `reasoning`; field replacement otherwise, with `undefined → null` so field-clears survive
179
+ `JSON.stringify`). `applySnapshotPatch(prev, patch)` reassembles it on the client. `WebSurface`
180
+ uses these; reuse them for your own transport.
181
+
182
+ ---
183
+
184
+ ## Control verbs (`LoomIO`)
185
+
186
+ ```ts
187
+ io.prompt({ prompt, agent?, sessionKey?, workdir?, model?, effort?, attachments? }) // → { sessionKey, taskId }
188
+ io.stop(sessionKey) // interrupt the running turn (queued tasks stay & promote)
189
+ io.steer(taskId, prompt, attachments?) // inject a message mid-turn (drivers with capabilities.steer)
190
+ io.interact(promptId, action, value?) // answer a pending interaction: 'select' | 'text' | 'skip' | 'cancel'
191
+ io.subscribe((sessionKey, snapshot, patch, seq) => …) // live snapshots
192
+ io.getSnapshot(sessionKey) · io.getHistory(sessionKey) · io.listSessions()
193
+ io.listAgentInfo() · io.listModels(agent) · io.listEffort(agent, model?) · io.listTools(agent, workdir?) · io.listSkills(agent, workdir?)
194
+ ```
195
+
196
+ Concurrent prompts to one session **queue** by default (`serialPerSession`, no clobber); `stop`
197
+ interrupts only the current turn and the next queued task promotes.
198
+
199
+ ---
200
+
201
+ ## Drivers (the agent axis)
202
+
203
+ | Driver | id | transport | steer | interact | resume | tui |
204
+ |--------|----|-----------|:-----:|:--------:|:------:|:---:|
205
+ | `ClaudeDriver` | `claude` | `claude` CLI, stream-json (+ `--effort`, partial messages) | ✓ | — | ✓ | ✓ |
206
+ | `CodexDriver` | `codex` | `codex app-server` JSON-RPC (HITL via `requestUserInput`) | ✓ | via askUser | ✓ | ✓ |
207
+ | `GeminiDriver` | `gemini` | `gemini --output-format stream-json` | — | — | ✓ | ✓ |
208
+ | `HermesDriver` | `hermes` | ACP `session/update` | — | — | ✓ | — |
209
+ | `EchoDriver` | `echo` | none (hermetic, in-process) | ✓ | ✓ | ✓ | ✓ |
210
+
211
+ Write your own by implementing `AgentDriver` and passing it to `createLoom({ drivers })` (or
212
+ `loom.registerDriver(...)`). A driver normalizes its agent into `DriverEvent`s:
213
+
214
+ ```ts
215
+ type DriverEvent =
216
+ | { type: 'session'; sessionId: string }
217
+ | { type: 'text'; delta: string }
218
+ | { type: 'reasoning'; delta: string }
219
+ | { type: 'tool'; call: UniversalToolCall } // emit on tool start AND completion (status update)
220
+ | { type: 'plan'; plan: UniversalPlan }
221
+ | { type: 'subagent'; subagent: UniversalSubAgent }
222
+ | { type: 'usage'; usage: Partial<UniversalUsage> }
223
+ | { type: 'artifact'; artifact: UniversalArtifact }
224
+ | { type: 'activity'; line: string }; // explicit activity line (drivers that don't emit tool events)
225
+
226
+ interface AgentDriver {
227
+ readonly id: string;
228
+ readonly capabilities?: { steer?: boolean; interact?: boolean; resume?: boolean; tui?: boolean };
229
+ run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>; // ctx: { signal, emit, askUser, registerSteer }
230
+ tui?(input: TuiInput): TuiSpec;
231
+ }
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Surfaces & Ports
237
+
238
+ **Surfaces** (上层) bind to `LoomIO`. Built in: `WebSurface` (a WebSocket host speaking the
239
+ wire protocol — any pikichannel-style client connects), `CliSurface`. Implement `Surface` to
240
+ add an IM channel or your own UI.
241
+
242
+ **Ports** (side) — all optional, each with a default, so `createLoom()` runs with zero config:
243
+
244
+ | Port | Default | Swap to… |
245
+ |------|---------|----------|
246
+ | `SessionStore` | `FsSessionStore` (`~/.<ns>/sessions`) | your DB / transcript store |
247
+ | `ModelResolver` | `NullModelResolver` (native login) | BYOK credential/provider injection |
248
+ | `ToolProvider` | `NoopToolProvider` | per-session MCP servers |
249
+ | `SystemPromptBuilder` | `PassthroughSystemPromptBuilder` | your system/developer prompt |
250
+ | `Catalog` | `NoopCatalog` | model/effort/tool/skill discovery for composers |
251
+ | `InteractionHandler` | `DeferToTerminalInteractionHandler` | programmatic HITL answers (`AutoCancelInteractionHandler` for one-shots) |
252
+
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
+ ```
67
265
 
68
- Wire shape: `UniversalSnapshot` (phase/text/reasoning/plan/toolCalls/usage/interactions/artifacts)
69
- + delta `diffSnapshot`/`applySnapshotPatch` (prefix-append text, `undefined→null` field-clears
70
- that survive JSON).
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.)
71
275
 
72
- ## What's included
276
+ ---
73
277
 
74
- - Drivers: `EchoDriver` (hermetic), `ClaudeDriver` (real `claude` CLI, stream-json + TUI), `CodexDriver` (app-server JSON-RPC + TUI), `GeminiDriver` (stream-json), `HermesDriver` (ACP).
75
- - Terminals: `WebSurface` (ws host speaking the wire protocol — any pikichannel client connects), `CliSurface`.
76
- - TUI: `PtyBridge` + `attachTui` + `Loom.runTui/openTui` (raw PTY passthrough with tee).
77
- - Ports: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`.
278
+ ## Exports
78
279
 
79
- ## Publishing / CI
280
+ Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel/drivers`,
281
+ `@pikiloom/kernel/surfaces`, `@pikiloom/kernel/protocol`.
80
282
 
81
- `npm publish ./packages/kernel --access public` (scoped-public). Each pikiloom release bumps
82
- + builds the kernel (`scripts/release.sh`) and the Release workflow publishes it alongside
83
- pikiloom needs the `@pikiloom` npm scope to exist and `NPM_TOKEN` to have publish access.
283
+ - Runtime: `createLoom`, `Loom`, `Hub`, `SessionRunner`, `runTurn`, `PtyBridge`, `ptyAvailable`, `attachTui`
284
+ - Drivers: `EchoDriver`, `ClaudeDriver`, `CodexDriver`, `GeminiDriver`, `HermesDriver`
285
+ - Surfaces: `WebSurface`, `CliSurface`
286
+ - Ports/defaults: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`, `DeferToTerminalInteractionHandler`, `NoopCatalog`, `defaultBaseDir`
287
+ - Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, all wire/`Client*`/`Server*` message types
288
+ - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
84
289
 
85
- > Known rough edge: node-pty's prebuilt `spawn-helper` can lose its executable bit on some
86
- > npm installs (`posix_spawnp failed`). Fix: `chmod +x node_modules/node-pty/prebuilds/*/spawn-helper`.
290
+ ---
87
291
 
88
- ## Verification
292
+ ## Verify
89
293
 
90
294
  ```bash
91
295
  npm run typecheck # tsc, clean
92
- npm test # hermetic: snapshot diff, full lifecycle (stop/steer/interact), web ws flow
296
+ npm test # hermetic: snapshot diff, full lifecycle (stop/steer/interact), web ws flow, driver parsers
93
297
  KERNEL_E2E_REAL=1 npm test # also drives the real `claude` CLI end-to-end
94
298
  node examples/smoke.mjs # smoke against the compiled dist
95
299
  ```
96
300
 
97
- Status: contracts + runtime + Echo/Claude drivers + Web terminal + default ports are
98
- implemented and **E2E-verified** (hermetic + real-claude + compiled-artifact). The kernel
99
- lives beside `src/` and does **not** touch the existing pikiloom app.
301
+ See `examples/` for a runnable web console, a Feishu/Lark terminal, and a Node smoke test.
100
302
 
101
- ## Roadmap (gated cutover)
303
+ ## License
102
304
 
103
- 1. Port the remaining drivers (Codex/Gemini/Hermes-ACP) behind the same `AgentDriver` contract.
104
- 2. Promote pikiloom's concrete IM channels to reference `Surface` adapters (`@pikiloom/kernel/surfaces/*`).
105
- 3. Add the full pikichannel transport (WebRTC + rendezvous + TURN + `/api` tunnel) as a `WebSurface` option.
106
- 4. Re-point the pikiloom app's `main.ts` at `createLoom({...})`; keep multi-session queue / promotion / goal
107
- auto-continuation in the app (not the kernel). Switch over only after parity is proven on DEV.
305
+ MIT
@@ -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 ADDED
@@ -0,0 +1,100 @@
1
+ # @pikiloom/kernel
2
+
3
+ > Reusable core that turns heterogeneous coding-agent CLIs (Claude Code, Codex, Gemini, ACP/Hermes)
4
+ > into ONE uniform, accumulating `UniversalSnapshot` + control verbs (prompt/stop/steer/interact),
5
+ > exposed over pluggable surfaces (IM, Web, tunnel, raw PTY). You read one snapshot shape and never
6
+ > parse a CLI's native output. ESM-only, Node >= 20. Runtime dep: `ws`; optional: `node-pty` (raw TUI).
7
+
8
+ This file is an LLM-oriented summary. Full prose: README.md. Exact types: the bundled `dist/**/*.d.ts`.
9
+
10
+ ## Install & import
11
+
12
+ ```
13
+ npm i @pikiloom/kernel
14
+ ```
15
+ ESM only. Main export re-exports everything; subpaths: `@pikiloom/kernel/drivers`, `/surfaces`, `/protocol`.
16
+
17
+ ## Three usage tiers
18
+
19
+ 1) ONE turn, no persistence — `runTurn(driver, input, opts?) => Promise<{ result, snapshot }>`:
20
+ ```ts
21
+ import { runTurn, ClaudeDriver } from '@pikiloom/kernel';
22
+ const { result, snapshot } = await runTurn(
23
+ new ClaudeDriver(),
24
+ { prompt: 'hi', workdir: process.cwd(), effort: 'high' }, // AgentTurnInput
25
+ { onSnapshot: (s) => render(s), onSteer: (steer) => {}, signal }, // RunTurnOptions (signal = AbortSignal to stop)
26
+ );
27
+ // result: { ok, text, reasoning?, error?, stopReason?, sessionId?, usage? }
28
+ ```
29
+
30
+ 2) FULL multi-session backend — `createLoom(config) => Loom`:
31
+ ```ts
32
+ import { createLoom, ClaudeDriver, WebSurface } from '@pikiloom/kernel';
33
+ const loom = createLoom({ drivers: [new ClaudeDriver()], surfaces: [new WebSurface({ port: 8787 })], defaultAgent: 'claude' });
34
+ await loom.start();
35
+ const { sessionKey, taskId } = await loom.io.prompt({ prompt: 'hi', agent: 'claude' });
36
+ const unsub = loom.io.subscribe((key, snapshot, patch, seq) => render(snapshot));
37
+ ```
38
+ Every port has a default (zero config). Override any: sessionStore, modelResolver, toolProvider, systemPromptBuilder, catalog, interactionHandler, plugins, serialPerSession, appNamespace, workdir, defaultAgent, log.
39
+
40
+ 3) RAW TUI passthrough — `loom.runTui({ agent, workdir })` (full stdio passthrough) or `loom.openTui(...) => PtyBridge` (onData/write/resize/onExit). Needs optional `node-pty`; `ptyAvailable()` reports it.
41
+
42
+ ## UniversalSnapshot — the shape you render (accumulates across a turn)
43
+
44
+ ```ts
45
+ interface UniversalSnapshot {
46
+ phase: 'idle'|'queued'|'streaming'|'done';
47
+ taskId?; sessionId?; agent?; model?; effort?; prompt?: string|null;
48
+ text?: string; // accumulated assistant output (append)
49
+ reasoning?: string; // accumulated thinking (append; empty if the model/auth withholds it — see gotchas)
50
+ activity?: string; // human-readable execution trail, kernel-derived from toolCalls (one line per tool, status suffix)
51
+ plan?: { explanation: string|null; steps: { text: string; status: 'pending'|'inProgress'|'completed' }[] } | null;
52
+ toolCalls?: { id; name; summary; input?; result?; status: 'running'|'done'|'failed' }[];
53
+ subAgents?: { id; kind; description; model; tools[]; status }[];
54
+ usage?: { inputTokens; outputTokens; cachedInputTokens; contextUsedTokens?; contextPercent; providerName? } | null;
55
+ artifacts?: { url?; path?; fileName; mime?; kind: 'photo'|'document'; caption? }[];
56
+ interactions?: { promptId; kind: 'user-input'|'permission'|'confirmation'; title; questions: { id; text; type?: 'text'|'select'; choices? }[] }[];
57
+ queued?: { taskId; prompt }[];
58
+ error?: string|null; incomplete?: boolean; startedAt?; updatedAt: number;
59
+ }
60
+ ```
61
+ Wire delta helpers: `diffSnapshot(prev,next) => SnapshotPatch` (prefix-append text/reasoning; undefined→null clears) and `applySnapshotPatch(prev,patch)`. `emptySnapshot()`, `PROTOCOL_VERSION`.
62
+
63
+ ## Control verbs (LoomIO, via loom.io)
64
+
65
+ - `prompt({ prompt, agent?, sessionKey?, workdir?, model?, effort?, attachments? }) => Promise<{ sessionKey, taskId }>`
66
+ - `stop(sessionKey) => boolean` (interrupts current turn; queued tasks stay and the next promotes)
67
+ - `steer(taskId, prompt, attachments?) => Promise<boolean>` (mid-turn; only drivers with capabilities.steer)
68
+ - `interact(promptId, 'select'|'text'|'skip'|'cancel', value?) => boolean` (answer a pending interaction)
69
+ - `subscribe((sessionKey, snapshot, patch, seq) => void) => unsubscribe`
70
+ - `getSnapshot(sessionKey)`, `getHistory(sessionKey)`, `listSessions()`, `listAgents()`, `listAgentInfo()`
71
+ - discovery: `listModels(agent)`, `listEffort(agent, model?)`, `listTools(agent, workdir?)`, `listSkills(agent, workdir?)`
72
+ Concurrent prompts to one session queue by default (serialPerSession=true).
73
+
74
+ ## Drivers (pass to createLoom({drivers}) or loom.registerDriver)
75
+
76
+ - ClaudeDriver (id 'claude'): `claude` CLI stream-json, supports --effort; steer ✓, resume ✓, tui ✓
77
+ - CodexDriver (id 'codex'): `codex app-server` JSON-RPC; steer ✓, HITL via askUser, resume ✓, tui ✓
78
+ - GeminiDriver (id 'gemini'): `gemini` stream-json; resume ✓, tui ✓
79
+ - HermesDriver (id 'hermes'): ACP session/update; resume ✓
80
+ - EchoDriver (id 'echo'): hermetic in-process test driver; all caps ✓
81
+
82
+ Write a driver: implement `AgentDriver { id; capabilities?; run(input, ctx); tui?(input) }`. In run(), call `ctx.emit(event)`:
83
+ `DriverEvent = {type:'session',sessionId} | {type:'text',delta} | {type:'reasoning',delta} | {type:'tool',call:UniversalToolCall} | {type:'plan',plan} | {type:'subagent',subagent} | {type:'usage',usage} | {type:'artifact',artifact} | {type:'activity',line}`.
84
+ `ctx = { signal: AbortSignal, emit, askUser(interaction)=>Promise<answers>, registerSteer(fn) }`. Return `DriverResult { ok, text, reasoning?, error?, stopReason?, sessionId?, usage? }`. Emit a `tool` event on BOTH start (status 'running') and completion (status 'done'/'failed'); the kernel projects `activity` from these.
85
+
86
+ ## Surfaces & Ports
87
+
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 = 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.
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)`.
93
+
94
+ ## Gotchas (read before using)
95
+
96
+ - ESM only; Node >= 20. `node-pty` is OPTIONAL — required only for tui()/runTui()/openTui(); a missing one degrades gracefully (`ptyAvailable()===false`), it never breaks import.
97
+ - `reasoning` is only populated when the agent streams plaintext thinking. Subscription/OAuth Claude withholds it (streams only an encrypted signature) → `reasoning` empty; BYOK Anthropic API and Codex DO stream it. Not a kernel bug.
98
+ - `activity` is derived by the kernel from structured `toolCalls`+`subAgents` (you don't build it). Rich UIs can use `toolCalls` directly; simple/text surfaces can use the `activity` string.
99
+ - The kernel bakes in ZERO model knowledge: model/effort/tool/skill lists come from the Catalog port (your app's SSOT). Per-agent capabilities come from the driver registry (`listAgentInfo()`).
100
+ - `stop` interrupts only the running turn (queued tasks promote); it is not "cancel everything".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.1.1",
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",
@@ -29,7 +29,8 @@
29
29
  "types": "./dist/index.d.ts",
30
30
  "files": [
31
31
  "dist/",
32
- "README.md"
32
+ "README.md",
33
+ "llms.txt"
33
34
  ],
34
35
  "scripts": {
35
36
  "build": "tsc -p tsconfig.json",