pikiloom 0.4.38 → 0.4.39
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/dashboard/dist/assets/{AgentTab-DbFzaIyZ.js → AgentTab-Ds8D6yrl.js} +1 -1
- package/dashboard/dist/assets/{ConnectionModal-QNYOWHCU.js → ConnectionModal-Buj2d5L5.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-BGaKHjFT.js → DirBrowser-BTv7rH8E.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-D4Gv9b8z.js → ExtensionsTab-BnGwxS9U.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-DxxoZd84.js → IMAccessTab-DjQnpOmF.js} +1 -1
- package/dashboard/dist/assets/{Modal-Nm2e93s5.js → Modal-DpuinsE3.js} +1 -1
- package/dashboard/dist/assets/{Modals-BwxZmU0U.js → Modals-Bs842H3n.js} +1 -1
- package/dashboard/dist/assets/{Select-SGlII0yx.js → Select-Dvia29HZ.js} +1 -1
- package/dashboard/dist/assets/SessionPanel-CVItEgcH.js +1 -0
- package/dashboard/dist/assets/{SystemTab-BF6lIAYM.js → SystemTab-BzPtwDxq.js} +1 -1
- package/dashboard/dist/assets/index-Bthwt6K_.css +1 -0
- package/dashboard/dist/assets/{index-DZiAiRNt.js → index-S0NmlDEH.js} +14 -14
- package/dashboard/dist/assets/{index-BP8R_bLT.js → index-yZ-iG1qk.js} +2 -2
- package/dashboard/dist/assets/{shared-S0kcs5yP.js → shared-p3kZpiD4.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/kernel-bridge.js +1 -0
- package/dist/agent/mcp/capabilities.js +39 -0
- package/dist/bot/bot.js +4 -13
- package/package.json +1 -1
- package/packages/kernel/README.md +263 -65
- package/packages/kernel/dist/contracts/surface.d.ts +17 -0
- package/packages/kernel/dist/drivers/claude.d.ts +12 -1
- package/packages/kernel/dist/drivers/claude.js +53 -6
- package/packages/kernel/dist/drivers/codex.d.ts +10 -0
- package/packages/kernel/dist/drivers/codex.js +79 -10
- package/packages/kernel/dist/index.d.ts +1 -1
- package/packages/kernel/dist/runtime/hub.d.ts +3 -0
- package/packages/kernel/dist/runtime/hub.js +84 -10
- package/packages/kernel/dist/runtime/loom.d.ts +1 -0
- package/packages/kernel/dist/runtime/loom.js +4 -1
- package/dashboard/dist/assets/SessionPanel-DYfSlreh.js +0 -1
- package/dashboard/dist/assets/index-CtS48Jn-.css +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Session tool capabilities: each co-locates a session MCP tool group (defined under
|
|
2
|
+
// ./tools/) with the prompt fragment that teaches the agent to use it. This is the local,
|
|
3
|
+
// decoupled analog of a @pikiloom/kernel Plugin (a capability = its tools + its usage
|
|
4
|
+
// prompt, as one unit). pikiloom composes its own system prompt app-side and feeds the
|
|
5
|
+
// kernel a finished string, so this module intentionally does NOT import the kernel — that
|
|
6
|
+
// is what keeps pikiloom fully decoupled from the kernel's Hub/plugin runtime.
|
|
7
|
+
const ARTIFACT_RETURN = [
|
|
8
|
+
'[Artifact Return]',
|
|
9
|
+
'To hand a file to the user — a screenshot, report, archive, generated asset, anything they asked you to "send" — call the `im_send_file` tool with the file path and a short caption. It is delivered through whatever terminal the user is on (an IM chat or the web dashboard) and stays retrievable even when they are connected remotely. Do NOT just print a local filesystem path: a remote user cannot open paths on this machine.',
|
|
10
|
+
].join('\n');
|
|
11
|
+
const ASK_USER = [
|
|
12
|
+
'[Asking the user]',
|
|
13
|
+
'The built-in `AskUserQuestion` tool is disabled here and will fail. If you would otherwise call it, call `mcp__pikiloom__im_ask_user` instead — same intent (a question plus optional choices), it blocks until the user replies via the IM/dashboard channel. Default behaviour is unchanged: infer obvious decisions yourself and only ask when you genuinely cannot proceed.',
|
|
14
|
+
].join('\n');
|
|
15
|
+
export const SESSION_TOOL_CAPABILITIES = [
|
|
16
|
+
{
|
|
17
|
+
id: 'artifact-delivery',
|
|
18
|
+
tools: ['im_send_file', 'im_list_files'],
|
|
19
|
+
promptFragment: () => ARTIFACT_RETURN,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'ask-user',
|
|
23
|
+
tools: ['im_ask_user'],
|
|
24
|
+
// Only when the HITL path is wired and the agent is claude (codex/gemini handle asks natively).
|
|
25
|
+
promptFragment: ({ agent, onInteraction }) => (onInteraction && agent === 'claude') ? ASK_USER : null,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
// Compose the session tool prompt = each capability's applicable fragment, in registration
|
|
29
|
+
// order, joined by a blank line. Byte-identical to the prior inline assembly in bot.ts.
|
|
30
|
+
export function composeSessionToolPrompt(ctx) {
|
|
31
|
+
const parts = [];
|
|
32
|
+
for (const cap of SESSION_TOOL_CAPABILITIES) {
|
|
33
|
+
const fragment = cap.promptFragment(ctx);
|
|
34
|
+
const trimmed = String(fragment || '').trim();
|
|
35
|
+
if (trimmed)
|
|
36
|
+
parts.push(trimmed);
|
|
37
|
+
}
|
|
38
|
+
return parts.join('\n\n');
|
|
39
|
+
}
|
package/dist/bot/bot.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getActiveProfileId, getActiveProfile, setActiveProfile } from '../model
|
|
|
8
8
|
import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
|
|
9
9
|
import { getDriver, hasDriver, allDriverIds, getDriverCapabilities } from '../agent/driver.js';
|
|
10
10
|
import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
|
|
11
|
+
import { composeSessionToolPrompt } from '../agent/mcp/capabilities.js';
|
|
11
12
|
import { terminateProcessTree } from '../core/process-control.js';
|
|
12
13
|
import { expandTilde } from '../core/platform.js';
|
|
13
14
|
import { VERSION } from '../core/version.js';
|
|
@@ -48,18 +49,6 @@ function appendExtraPrompt(base, extra) {
|
|
|
48
49
|
return lhs;
|
|
49
50
|
return `${lhs}\n\n${rhs}`;
|
|
50
51
|
}
|
|
51
|
-
function buildMcpDeliveryPrompt() {
|
|
52
|
-
return [
|
|
53
|
-
'[Artifact Return]',
|
|
54
|
-
'To hand a file to the user — a screenshot, report, archive, generated asset, anything they asked you to "send" — call the `im_send_file` tool with the file path and a short caption. It is delivered through whatever terminal the user is on (an IM chat or the web dashboard) and stays retrievable even when they are connected remotely. Do NOT just print a local filesystem path: a remote user cannot open paths on this machine.',
|
|
55
|
-
].join('\n');
|
|
56
|
-
}
|
|
57
|
-
function buildClaudeAskUserPrompt() {
|
|
58
|
-
return [
|
|
59
|
-
'[Asking the user]',
|
|
60
|
-
'The built-in `AskUserQuestion` tool is disabled here and will fail. If you would otherwise call it, call `mcp__pikiloom__im_ask_user` instead — same intent (a question plus optional choices), it blocks until the user replies via the IM/dashboard channel. Default behaviour is unchanged: infer obvious decisions yourself and only ask when you genuinely cannot proceed.',
|
|
61
|
-
].join('\n');
|
|
62
|
-
}
|
|
63
52
|
function buildBrowserAutomationPrompt(browserEnabled) {
|
|
64
53
|
if (!browserEnabled) {
|
|
65
54
|
return [
|
|
@@ -2022,7 +2011,9 @@ export class Bot {
|
|
|
2022
2011
|
const workflowEnabled = cs.agent === 'claude' && (extras?.workflowEnabled ?? this.claudeWorkflowEnabled);
|
|
2023
2012
|
const deliverySessionKey = ('key' in cs && typeof cs.key === 'string') ? cs.key : null;
|
|
2024
2013
|
const wrappedSendFile = this.buildArtifactSendFile(cs.agent, deliverySessionKey, cs, mcpSendFile);
|
|
2025
|
-
|
|
2014
|
+
// Session tool capabilities (im_send_file / im_ask_user) bundle their usage prompt with
|
|
2015
|
+
// the tool itself — see agent/mcp/capabilities.ts. Browser/workflow remain session policy.
|
|
2016
|
+
const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(composeSessionToolPrompt({ agent: cs.agent, onInteraction: !!onInteraction }), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
|
|
2026
2017
|
const effectiveSystemPrompt = isFirstTurnOfSession
|
|
2027
2018
|
? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
|
|
2028
2019
|
: (mcpSystemPrompt || undefined);
|
package/package.json
CHANGED
|
@@ -1,107 +1,305 @@
|
|
|
1
1
|
# @pikiloom/kernel
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
accumulating session snapshot + control
|
|
5
|
-
(IM, Web, tunnel). Environmental concerns
|
|
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
|
|
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
|
-
|
|
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
|
|
25
|
+
上层 (you write): IM bindings · Web UI · Plugins ── implement Surface / Plugin
|
|
14
26
|
▲ createLoom({ surfaces, plugins, ...ports })
|
|
15
|
-
@pikiloom/kernel:
|
|
16
|
-
(one import, runtime/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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** —
|
|
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
|
-
|
|
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 {
|
|
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()],
|
|
33
|
-
surfaces: [new WebSurface({ port: 8787 })],
|
|
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
|
-
|
|
39
|
-
`io.prompt(...)` and render `io.subscribe(...)` snapshots back.
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
276
|
+
---
|
|
73
277
|
|
|
74
|
-
|
|
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
|
-
|
|
280
|
+
Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel/drivers`,
|
|
281
|
+
`@pikiloom/kernel/surfaces`, `@pikiloom/kernel/protocol`.
|
|
80
282
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
> npm installs (`posix_spawnp failed`). Fix: `chmod +x node_modules/node-pty/prebuilds/*/spawn-helper`.
|
|
290
|
+
---
|
|
87
291
|
|
|
88
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
303
|
+
## License
|
|
102
304
|
|
|
103
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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;
|