pikiloom 0.4.37 → 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 +207 -0
- package/dist/agent/mcp/capabilities.js +39 -0
- package/dist/agent/stream.js +19 -1
- package/dist/bot/bot.js +4 -13
- package/dist/cli/kernel-app.js +115 -0
- package/dist/cli/main.js +7 -0
- package/package.json +4 -2
- package/packages/kernel/README.md +305 -0
- package/packages/kernel/dist/contracts/driver.d.ts +95 -0
- package/packages/kernel/dist/contracts/driver.js +1 -0
- package/packages/kernel/dist/contracts/ports.d.ts +84 -0
- package/packages/kernel/dist/contracts/ports.js +1 -0
- package/packages/kernel/dist/contracts/surface.d.ts +92 -0
- package/packages/kernel/dist/contracts/surface.js +1 -0
- package/packages/kernel/dist/drivers/claude.d.ts +34 -0
- package/packages/kernel/dist/drivers/claude.js +500 -0
- package/packages/kernel/dist/drivers/codex.d.ts +24 -0
- package/packages/kernel/dist/drivers/codex.js +415 -0
- package/packages/kernel/dist/drivers/echo.d.ts +20 -0
- package/packages/kernel/dist/drivers/echo.js +61 -0
- package/packages/kernel/dist/drivers/gemini.d.ts +18 -0
- package/packages/kernel/dist/drivers/gemini.js +143 -0
- package/packages/kernel/dist/drivers/hermes.d.ts +14 -0
- package/packages/kernel/dist/drivers/hermes.js +194 -0
- package/packages/kernel/dist/drivers/index.d.ts +5 -0
- package/packages/kernel/dist/drivers/index.js +5 -0
- package/packages/kernel/dist/index.d.ts +18 -0
- package/packages/kernel/dist/index.js +29 -0
- package/packages/kernel/dist/ports/defaults.d.ts +59 -0
- package/packages/kernel/dist/ports/defaults.js +137 -0
- package/packages/kernel/dist/protocol/index.d.ts +309 -0
- package/packages/kernel/dist/protocol/index.js +59 -0
- package/packages/kernel/dist/runtime/hub.d.ts +68 -0
- package/packages/kernel/dist/runtime/hub.js +334 -0
- package/packages/kernel/dist/runtime/loom.d.ts +45 -0
- package/packages/kernel/dist/runtime/loom.js +72 -0
- package/packages/kernel/dist/runtime/pty.d.ts +23 -0
- package/packages/kernel/dist/runtime/pty.js +69 -0
- package/packages/kernel/dist/runtime/session-runner.d.ts +27 -0
- package/packages/kernel/dist/runtime/session-runner.js +210 -0
- package/packages/kernel/dist/runtime/tui.d.ts +8 -0
- package/packages/kernel/dist/runtime/tui.js +35 -0
- package/packages/kernel/dist/runtime/turn.d.ts +17 -0
- package/packages/kernel/dist/runtime/turn.js +16 -0
- package/packages/kernel/dist/surfaces/cli.d.ts +19 -0
- package/packages/kernel/dist/surfaces/cli.js +65 -0
- package/packages/kernel/dist/surfaces/index.d.ts +2 -0
- package/packages/kernel/dist/surfaces/index.js +2 -0
- package/packages/kernel/dist/surfaces/web.d.ts +39 -0
- package/packages/kernel/dist/surfaces/web.js +244 -0
- package/dashboard/dist/assets/SessionPanel-DYfSlreh.js +0 -1
- package/dashboard/dist/assets/index-CtS48Jn-.css +0 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { diffSnapshot, emptySnapshot, } from '../protocol/index.js';
|
|
3
|
+
import { SessionRunner } from './session-runner.js';
|
|
4
|
+
function splitKey(sessionKey) {
|
|
5
|
+
const i = sessionKey.indexOf(':');
|
|
6
|
+
return i < 0 ? { agent: '', sessionId: sessionKey } : { agent: sessionKey.slice(0, i), sessionId: sessionKey.slice(i + 1) };
|
|
7
|
+
}
|
|
8
|
+
export class Hub {
|
|
9
|
+
deps;
|
|
10
|
+
sessions = new Map();
|
|
11
|
+
runnersByTask = new Map();
|
|
12
|
+
active = new Map(); // sessionKey -> currently running turn
|
|
13
|
+
waiting = new Map(); // sessionKey -> FIFO of queued turns
|
|
14
|
+
seqByKey = new Map(); // monotonic publish seq per session (survives turn changes)
|
|
15
|
+
updateSubs = new Set();
|
|
16
|
+
sessionsSubs = new Set();
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
this.deps = deps;
|
|
19
|
+
}
|
|
20
|
+
listAgents() { return [...this.deps.drivers.keys()]; }
|
|
21
|
+
// ---- discovery (capabilities from drivers; models/effort/tools/skills from Catalog) ----
|
|
22
|
+
listAgentInfo() {
|
|
23
|
+
return [...this.deps.drivers.values()].map(d => ({ id: d.id, capabilities: d.capabilities }));
|
|
24
|
+
}
|
|
25
|
+
listModels(agent) {
|
|
26
|
+
return this.deps.catalog.listModels({ agent }).catch((e) => { this.deps.log?.(`[hub] listModels failed: ${e?.message || e}`); return []; });
|
|
27
|
+
}
|
|
28
|
+
listEffort(agent, model) {
|
|
29
|
+
return this.deps.catalog.listEffort({ agent, model: model ?? null }).catch((e) => { this.deps.log?.(`[hub] listEffort failed: ${e?.message || e}`); return []; });
|
|
30
|
+
}
|
|
31
|
+
listTools(agent, workdir) {
|
|
32
|
+
return this.deps.catalog.listTools({ agent, workdir: workdir || this.deps.workdir }).catch((e) => { this.deps.log?.(`[hub] listTools failed: ${e?.message || e}`); return []; });
|
|
33
|
+
}
|
|
34
|
+
listSkills(agent, workdir) {
|
|
35
|
+
return this.deps.catalog.listSkills({ agent, workdir: workdir || this.deps.workdir }).catch((e) => { this.deps.log?.(`[hub] listSkills failed: ${e?.message || e}`); return []; });
|
|
36
|
+
}
|
|
37
|
+
// Resolve how to launch an agent's interactive TUI (with model injection applied),
|
|
38
|
+
// without spawning it — the caller (launcher / Loom.runTui) owns the PTY.
|
|
39
|
+
async resolveTui(opts) {
|
|
40
|
+
const agent = (opts.agent || this.deps.defaultAgent || '').trim();
|
|
41
|
+
const driver = this.deps.drivers.get(agent);
|
|
42
|
+
if (!driver)
|
|
43
|
+
throw new Error(`No driver registered for agent "${agent}"`);
|
|
44
|
+
if (!driver.tui)
|
|
45
|
+
throw new Error(`Driver "${agent}" does not support TUI mode`);
|
|
46
|
+
const workdir = opts.workdir || this.deps.workdir;
|
|
47
|
+
const injection = await this.deps.modelResolver.resolve(agent, { model: opts.model, profileId: null }).catch(() => null);
|
|
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 });
|
|
53
|
+
}
|
|
54
|
+
async prompt(input) {
|
|
55
|
+
const agent = (input.agent || this.deps.defaultAgent || '').trim();
|
|
56
|
+
const driver = this.deps.drivers.get(agent);
|
|
57
|
+
if (!driver)
|
|
58
|
+
throw new Error(`No driver registered for agent "${agent}"`);
|
|
59
|
+
const workdir = input.workdir || this.deps.workdir;
|
|
60
|
+
const resumeId = input.sessionKey ? splitKey(input.sessionKey).sessionId : null;
|
|
61
|
+
const preExisted = resumeId ? !!(await this.deps.sessionStore.get(agent, resumeId)) : false;
|
|
62
|
+
const { sessionId, workspacePath } = await this.deps.sessionStore.ensure(agent, {
|
|
63
|
+
sessionId: resumeId, workdir, title: input.prompt.slice(0, 80),
|
|
64
|
+
});
|
|
65
|
+
const sessionKey = `${agent}:${sessionId}`;
|
|
66
|
+
const taskId = randomUUID();
|
|
67
|
+
const runner = new SessionRunner(sessionKey, agent, taskId, (snap, seq) => this.onRunnerUpdate(sessionKey, snap, seq), this.deps.interactionHandler);
|
|
68
|
+
this.runnersByTask.set(taskId, runner);
|
|
69
|
+
const item = { runner, taskId, input, driver, agent, sessionId, sessionKey, workdir, workspacePath, preExisted };
|
|
70
|
+
// Per-session serial orchestration (default): one turn per session at a time. A prompt
|
|
71
|
+
// for a busy session queues (surfaced in the active snapshot's `queued`) and promotes
|
|
72
|
+
// when the running turn finishes — never clobbering the in-flight turn's snapshot.
|
|
73
|
+
if (this.deps.serialPerSession !== false && this.active.has(sessionKey)) {
|
|
74
|
+
this.enqueue(item);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
void this.runNow(item);
|
|
78
|
+
}
|
|
79
|
+
return { sessionKey, taskId };
|
|
80
|
+
}
|
|
81
|
+
enqueue(item) {
|
|
82
|
+
const q = this.waiting.get(item.sessionKey) ?? [];
|
|
83
|
+
q.push(item);
|
|
84
|
+
this.waiting.set(item.sessionKey, q);
|
|
85
|
+
this.publishQueued(item.sessionKey);
|
|
86
|
+
}
|
|
87
|
+
// Build and run one turn. The sync prefix (mark active, install the session entry) runs
|
|
88
|
+
// before the first await, so getSnapshot() is valid the instant prompt() returns.
|
|
89
|
+
async runNow(item) {
|
|
90
|
+
const { runner, taskId, input, driver, agent, sessionId, sessionKey, workdir, workspacePath, preExisted } = item;
|
|
91
|
+
this.active.set(sessionKey, runner);
|
|
92
|
+
const prev = this.sessions.get(sessionKey);
|
|
93
|
+
const entry = {
|
|
94
|
+
meta: { sessionKey, agent, title: input.prompt.slice(0, 80), phase: 'streaming', updatedAt: Date.now() },
|
|
95
|
+
// Inherit the prior turn's published state so this turn's FIRST diff correctly resets
|
|
96
|
+
// text/tools/usage/queued on the client — no cross-turn bleed on the same session.
|
|
97
|
+
snapshot: runner.snapshot, lastPublished: prev?.lastPublished ?? emptySnapshot(), seq: prev?.seq ?? 0, runner,
|
|
98
|
+
};
|
|
99
|
+
this.sessions.set(sessionKey, entry);
|
|
100
|
+
this.emitSessionsChanged();
|
|
101
|
+
// Resolve injection / tools / resume-target at run time so a promoted turn sees the
|
|
102
|
+
// prior turn's native session id and any refreshed credentials/tools.
|
|
103
|
+
const rec = await this.deps.sessionStore.get(agent, sessionId);
|
|
104
|
+
const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
|
|
105
|
+
const model = injection?.model ?? input.model ?? null;
|
|
106
|
+
const effort = input.effort ?? null;
|
|
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);
|
|
117
|
+
const turnInput = {
|
|
118
|
+
prompt: input.prompt,
|
|
119
|
+
attachments: input.attachments,
|
|
120
|
+
sessionId: rec?.nativeSessionId || (input.sessionKey ? sessionId : null),
|
|
121
|
+
workdir,
|
|
122
|
+
model, effort, systemPrompt,
|
|
123
|
+
env: spawn.env,
|
|
124
|
+
extraArgs: spawn.extraArgs,
|
|
125
|
+
configOverrides: spawn.configOverrides,
|
|
126
|
+
extraMcpServers: tools.servers,
|
|
127
|
+
};
|
|
128
|
+
runner.run(driver, turnInput, input.prompt, model, effort)
|
|
129
|
+
.then(async (result) => {
|
|
130
|
+
await this.deps.sessionStore.recordResult(agent, sessionId, result);
|
|
131
|
+
// Persist the completed turn's final snapshot as a transcript entry. The runner's
|
|
132
|
+
// snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
|
|
133
|
+
try {
|
|
134
|
+
await this.deps.sessionStore.appendTurn?.(agent, sessionId, runner.snapshot);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
this.deps.log?.(`[hub] appendTurn failed ${sessionKey}: ${e?.message || e}`);
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.catch((err) => this.deps.log?.(`[hub] run error ${sessionKey}: ${err?.message || err}`))
|
|
141
|
+
.finally(() => {
|
|
142
|
+
this.runnersByTask.delete(taskId);
|
|
143
|
+
if (this.active.get(sessionKey) === runner)
|
|
144
|
+
this.active.delete(sessionKey);
|
|
145
|
+
this.promoteNext(sessionKey); // serial: start the next queued turn (queue survives stop)
|
|
146
|
+
this.emitSessionsChanged();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
promoteNext(sessionKey) {
|
|
150
|
+
const q = this.waiting.get(sessionKey);
|
|
151
|
+
if (!q || q.length === 0) {
|
|
152
|
+
this.waiting.delete(sessionKey);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const next = q.shift();
|
|
156
|
+
if (q.length === 0)
|
|
157
|
+
this.waiting.delete(sessionKey);
|
|
158
|
+
void this.runNow(next);
|
|
159
|
+
}
|
|
160
|
+
queueView(sessionKey) {
|
|
161
|
+
return (this.waiting.get(sessionKey) || []).map(it => ({ taskId: it.taskId, prompt: it.input.prompt }));
|
|
162
|
+
}
|
|
163
|
+
// Re-publish the active turn's snapshot so a queue change (enqueue/drain) reaches subscribers.
|
|
164
|
+
publishQueued(sessionKey) {
|
|
165
|
+
const entry = this.sessions.get(sessionKey);
|
|
166
|
+
if (entry?.runner)
|
|
167
|
+
this.onRunnerUpdate(sessionKey, entry.runner.snapshot, entry.runner.currentSeq);
|
|
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.
|
|
171
|
+
async collectTools(agent, workdir, workspacePath) {
|
|
172
|
+
const servers = [];
|
|
173
|
+
let env;
|
|
174
|
+
try {
|
|
175
|
+
const base = await this.deps.toolProvider.provideForSession({ agent, workdir, workspacePath });
|
|
176
|
+
servers.push(...base.servers);
|
|
177
|
+
if (base.env && Object.keys(base.env).length)
|
|
178
|
+
env = base.env;
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
this.deps.log?.(`[hub] toolProvider failed: ${e?.message || e}`);
|
|
182
|
+
}
|
|
183
|
+
for (const plugin of this.deps.plugins) {
|
|
184
|
+
if (!plugin.tools)
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
servers.push(...(await plugin.tools({ agent, workdir })));
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
this.deps.log?.(`[hub] plugin ${plugin.id} tools failed: ${e?.message || e}`);
|
|
191
|
+
}
|
|
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
|
+
}
|
|
228
|
+
return out;
|
|
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
|
+
}
|
|
252
|
+
onRunnerUpdate(sessionKey, snapshot, _seq) {
|
|
253
|
+
const entry = this.sessions.get(sessionKey);
|
|
254
|
+
if (!entry)
|
|
255
|
+
return;
|
|
256
|
+
let decorated = snapshot;
|
|
257
|
+
for (const plugin of this.deps.plugins) {
|
|
258
|
+
if (plugin.decorateSnapshot)
|
|
259
|
+
decorated = plugin.decorateSnapshot(decorated);
|
|
260
|
+
}
|
|
261
|
+
const queued = this.queueView(sessionKey);
|
|
262
|
+
decorated = { ...decorated, queued: queued.length ? queued : undefined };
|
|
263
|
+
entry.snapshot = decorated;
|
|
264
|
+
entry.seq = (this.seqByKey.get(sessionKey) ?? entry.seq ?? 0) + 1; // monotonic per session across turns
|
|
265
|
+
this.seqByKey.set(sessionKey, entry.seq);
|
|
266
|
+
entry.meta = { ...entry.meta, phase: decorated.phase, updatedAt: decorated.updatedAt, title: entry.meta.title || decorated.prompt?.slice(0, 80) || null };
|
|
267
|
+
const patch = diffSnapshot(entry.lastPublished, decorated);
|
|
268
|
+
entry.lastPublished = JSON.parse(JSON.stringify(decorated));
|
|
269
|
+
for (const cb of this.updateSubs) {
|
|
270
|
+
try {
|
|
271
|
+
cb(sessionKey, decorated, patch, entry.seq);
|
|
272
|
+
}
|
|
273
|
+
catch { /* isolate subscriber */ }
|
|
274
|
+
}
|
|
275
|
+
if (decorated.phase === 'done')
|
|
276
|
+
this.emitSessionsChanged();
|
|
277
|
+
}
|
|
278
|
+
// ---- control verbs ----
|
|
279
|
+
stop(sessionKey) {
|
|
280
|
+
const r = this.sessions.get(sessionKey)?.runner;
|
|
281
|
+
if (!r)
|
|
282
|
+
return false;
|
|
283
|
+
r.stop();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
async steer(taskId, prompt, attachments) {
|
|
287
|
+
const r = this.runnersByTask.get(taskId);
|
|
288
|
+
return r ? r.steer(prompt, attachments) : false;
|
|
289
|
+
}
|
|
290
|
+
interact(promptId, action, value) {
|
|
291
|
+
for (const r of this.runnersByTask.values()) {
|
|
292
|
+
if (r.interact(promptId, action, value))
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
// ---- subscriptions & queries ----
|
|
298
|
+
subscribe(cb) {
|
|
299
|
+
this.updateSubs.add(cb);
|
|
300
|
+
return () => this.updateSubs.delete(cb);
|
|
301
|
+
}
|
|
302
|
+
onSessionsChanged(cb) {
|
|
303
|
+
this.sessionsSubs.add(cb);
|
|
304
|
+
return () => this.sessionsSubs.delete(cb);
|
|
305
|
+
}
|
|
306
|
+
emitSessionsChanged() {
|
|
307
|
+
const list = this.listSessions();
|
|
308
|
+
for (const cb of this.sessionsSubs) {
|
|
309
|
+
try {
|
|
310
|
+
cb(list);
|
|
311
|
+
}
|
|
312
|
+
catch { /* isolate */ }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
listSessions() {
|
|
316
|
+
return [...this.sessions.values()].map(e => e.meta).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
317
|
+
}
|
|
318
|
+
getSnapshot(sessionKey) {
|
|
319
|
+
const e = this.sessions.get(sessionKey);
|
|
320
|
+
return e ? { snapshot: e.snapshot, seq: e.seq } : null;
|
|
321
|
+
}
|
|
322
|
+
async getHistory(sessionKey) {
|
|
323
|
+
const { agent, sessionId } = splitKey(sessionKey);
|
|
324
|
+
if (!agent || !this.deps.sessionStore.history)
|
|
325
|
+
return [];
|
|
326
|
+
try {
|
|
327
|
+
return await this.deps.sessionStore.history(agent, sessionId);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
this.deps.log?.(`[hub] history failed ${sessionKey}: ${e?.message || e}`);
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentDriver, TuiSpec } from '../contracts/driver.js';
|
|
2
|
+
import type { SessionStore, ModelResolver, ToolProvider, SystemPromptBuilder, Catalog, InteractionHandler } from '../contracts/ports.js';
|
|
3
|
+
import type { LoomIO, Surface, Plugin } from '../contracts/surface.js';
|
|
4
|
+
import { PtyBridge, type PtyOpenOpts, type PtyExit } from './pty.js';
|
|
5
|
+
export interface TuiLaunchOptions {
|
|
6
|
+
agent?: string;
|
|
7
|
+
workdir?: string;
|
|
8
|
+
model?: string | null;
|
|
9
|
+
sessionId?: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface LoomConfig {
|
|
12
|
+
appNamespace?: string;
|
|
13
|
+
workdir?: string;
|
|
14
|
+
defaultAgent?: string;
|
|
15
|
+
drivers?: AgentDriver[];
|
|
16
|
+
surfaces?: Surface[];
|
|
17
|
+
plugins?: Plugin[];
|
|
18
|
+
sessionStore?: SessionStore;
|
|
19
|
+
modelResolver?: ModelResolver;
|
|
20
|
+
toolProvider?: ToolProvider;
|
|
21
|
+
systemPromptBuilder?: SystemPromptBuilder;
|
|
22
|
+
catalog?: Catalog;
|
|
23
|
+
interactionHandler?: InteractionHandler;
|
|
24
|
+
serialPerSession?: boolean;
|
|
25
|
+
systemPromptBase?: string;
|
|
26
|
+
log?: (msg: string) => void;
|
|
27
|
+
}
|
|
28
|
+
export interface Loom {
|
|
29
|
+
readonly io: LoomIO;
|
|
30
|
+
registerDriver(driver: AgentDriver): void;
|
|
31
|
+
registerPlugin(plugin: Plugin): void;
|
|
32
|
+
start(): Promise<void>;
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
status(): {
|
|
35
|
+
agents: string[];
|
|
36
|
+
surfaces: string[];
|
|
37
|
+
started: boolean;
|
|
38
|
+
};
|
|
39
|
+
resolveTui(opts: TuiLaunchOptions): Promise<TuiSpec>;
|
|
40
|
+
openTui(opts: TuiLaunchOptions & PtyOpenOpts): Promise<PtyBridge>;
|
|
41
|
+
runTui(opts: TuiLaunchOptions & {
|
|
42
|
+
observers?: Array<(c: string) => void>;
|
|
43
|
+
}): Promise<PtyExit>;
|
|
44
|
+
}
|
|
45
|
+
export declare function createLoom(config?: LoomConfig): Loom;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, } from '../ports/defaults.js';
|
|
2
|
+
import { Hub } from './hub.js';
|
|
3
|
+
import { PtyBridge } from './pty.js';
|
|
4
|
+
import { attachTui } from './tui.js';
|
|
5
|
+
export function createLoom(config = {}) {
|
|
6
|
+
const appNamespace = config.appNamespace || 'loom';
|
|
7
|
+
const workdir = config.workdir || process.cwd();
|
|
8
|
+
const log = config.log || (() => { });
|
|
9
|
+
const drivers = new Map();
|
|
10
|
+
for (const d of config.drivers || [])
|
|
11
|
+
drivers.set(d.id, d);
|
|
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 || [])];
|
|
15
|
+
const hub = new Hub({
|
|
16
|
+
drivers,
|
|
17
|
+
defaultAgent,
|
|
18
|
+
workdir,
|
|
19
|
+
sessionStore: config.sessionStore || new FsSessionStore(defaultBaseDir(appNamespace)),
|
|
20
|
+
modelResolver: config.modelResolver || new NullModelResolver(),
|
|
21
|
+
toolProvider: config.toolProvider || new NoopToolProvider(),
|
|
22
|
+
systemPromptBuilder: config.systemPromptBuilder || new PassthroughSystemPromptBuilder(),
|
|
23
|
+
catalog: config.catalog || new NoopCatalog(),
|
|
24
|
+
interactionHandler: config.interactionHandler || new DeferToTerminalInteractionHandler(),
|
|
25
|
+
serialPerSession: config.serialPerSession,
|
|
26
|
+
plugins,
|
|
27
|
+
systemPromptBase: config.systemPromptBase,
|
|
28
|
+
log,
|
|
29
|
+
});
|
|
30
|
+
const surfaces = config.surfaces || [];
|
|
31
|
+
let started = false;
|
|
32
|
+
// Lane R opener (raw PTY): resolve the agent's TUI spec (with model injection) and
|
|
33
|
+
// spawn it in a PtyBridge. Shared by Loom.openTui and the TuiHost given to surfaces.
|
|
34
|
+
const openTui = async (opts) => {
|
|
35
|
+
const spec = await hub.resolveTui(opts);
|
|
36
|
+
return PtyBridge.open(spec, { cols: opts.cols, rows: opts.rows });
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
io: hub,
|
|
40
|
+
registerDriver(driver) { drivers.set(driver.id, driver); },
|
|
41
|
+
registerPlugin(plugin) { plugins.push(plugin); },
|
|
42
|
+
async start() {
|
|
43
|
+
if (started)
|
|
44
|
+
return;
|
|
45
|
+
started = true;
|
|
46
|
+
for (const t of surfaces) {
|
|
47
|
+
await t.start(hub, { openTui }); // hub = Lane S (LoomIO); openTui = Lane R
|
|
48
|
+
log(`[loom] terminal started: ${t.id}`);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
async stop() {
|
|
52
|
+
if (!started)
|
|
53
|
+
return;
|
|
54
|
+
started = false;
|
|
55
|
+
for (const t of surfaces) {
|
|
56
|
+
try {
|
|
57
|
+
await t.stop();
|
|
58
|
+
}
|
|
59
|
+
catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
status() {
|
|
63
|
+
return { agents: [...drivers.keys()], surfaces: surfaces.map(t => t.id), started };
|
|
64
|
+
},
|
|
65
|
+
resolveTui(opts) { return hub.resolveTui(opts); },
|
|
66
|
+
openTui,
|
|
67
|
+
async runTui(opts) {
|
|
68
|
+
const spec = await hub.resolveTui(opts);
|
|
69
|
+
return attachTui(spec, { observers: opts.observers });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TuiSpec } from '../contracts/driver.js';
|
|
2
|
+
export declare function ptyAvailable(): boolean;
|
|
3
|
+
export interface PtyOpenOpts {
|
|
4
|
+
cols?: number;
|
|
5
|
+
rows?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface PtyExit {
|
|
8
|
+
exitCode: number;
|
|
9
|
+
signal?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class PtyBridge {
|
|
12
|
+
private readonly proc;
|
|
13
|
+
private readonly dataCbs;
|
|
14
|
+
private readonly exitCbs;
|
|
15
|
+
private constructor();
|
|
16
|
+
static open(spec: TuiSpec, opts?: PtyOpenOpts): PtyBridge;
|
|
17
|
+
onData(cb: (d: string) => void): () => void;
|
|
18
|
+
onExit(cb: (e: PtyExit) => void): () => void;
|
|
19
|
+
write(data: string): void;
|
|
20
|
+
resize(cols: number, rows: number): void;
|
|
21
|
+
kill(signal?: string): void;
|
|
22
|
+
get pid(): number;
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
let _pty;
|
|
4
|
+
function loadPty() {
|
|
5
|
+
if (_pty === undefined) {
|
|
6
|
+
try {
|
|
7
|
+
_pty = require('node-pty');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
_pty = null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return _pty;
|
|
14
|
+
}
|
|
15
|
+
export function ptyAvailable() { return !!loadPty(); }
|
|
16
|
+
// A pseudo-terminal bridge around an agent's interactive process. Carries raw TUI bytes
|
|
17
|
+
// in both directions and fans `onData` out to many observers (full passthrough + tee/mirror).
|
|
18
|
+
// This is the capability the kernel previously lacked: transparently passing through
|
|
19
|
+
// Claude/Codex TUI traffic instead of only the structured -p stream.
|
|
20
|
+
export class PtyBridge {
|
|
21
|
+
proc;
|
|
22
|
+
dataCbs = new Set();
|
|
23
|
+
exitCbs = new Set();
|
|
24
|
+
constructor(proc) {
|
|
25
|
+
this.proc = proc;
|
|
26
|
+
proc.onData((d) => { for (const cb of this.dataCbs) {
|
|
27
|
+
try {
|
|
28
|
+
cb(d);
|
|
29
|
+
}
|
|
30
|
+
catch { /* isolate */ }
|
|
31
|
+
} });
|
|
32
|
+
proc.onExit((e) => {
|
|
33
|
+
for (const cb of this.exitCbs) {
|
|
34
|
+
try {
|
|
35
|
+
cb({ exitCode: e.exitCode, signal: e.signal });
|
|
36
|
+
}
|
|
37
|
+
catch { /* isolate */ }
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
static open(spec, opts = {}) {
|
|
42
|
+
const pty = loadPty();
|
|
43
|
+
if (!pty)
|
|
44
|
+
throw new Error('node-pty is not available — install the optional dependency `node-pty` to use TUI passthrough.');
|
|
45
|
+
const proc = pty.spawn(spec.command, spec.args, {
|
|
46
|
+
name: 'xterm-256color',
|
|
47
|
+
cols: opts.cols || 80,
|
|
48
|
+
rows: opts.rows || 24,
|
|
49
|
+
cwd: spec.cwd,
|
|
50
|
+
env: { ...process.env, ...(spec.env || {}) },
|
|
51
|
+
});
|
|
52
|
+
return new PtyBridge(proc);
|
|
53
|
+
}
|
|
54
|
+
onData(cb) { this.dataCbs.add(cb); return () => this.dataCbs.delete(cb); }
|
|
55
|
+
onExit(cb) { this.exitCbs.add(cb); return () => this.exitCbs.delete(cb); }
|
|
56
|
+
write(data) { try {
|
|
57
|
+
this.proc.write(data);
|
|
58
|
+
}
|
|
59
|
+
catch { /* closed */ } }
|
|
60
|
+
resize(cols, rows) { try {
|
|
61
|
+
this.proc.resize(Math.max(1, cols), Math.max(1, rows));
|
|
62
|
+
}
|
|
63
|
+
catch { /* closed */ } }
|
|
64
|
+
kill(signal) { try {
|
|
65
|
+
this.proc.kill(signal);
|
|
66
|
+
}
|
|
67
|
+
catch { /* closed */ } }
|
|
68
|
+
get pid() { return this.proc.pid; }
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { UniversalSnapshot } from '../protocol/index.js';
|
|
2
|
+
import type { AgentDriver, AgentTurnInput, DriverResult } from '../contracts/driver.js';
|
|
3
|
+
import type { InteractionHandler } from '../contracts/ports.js';
|
|
4
|
+
export declare class SessionRunner {
|
|
5
|
+
readonly sessionKey: string;
|
|
6
|
+
readonly agent: string;
|
|
7
|
+
readonly taskId: string;
|
|
8
|
+
private readonly onUpdate;
|
|
9
|
+
private readonly interactionHandler?;
|
|
10
|
+
readonly snapshot: UniversalSnapshot;
|
|
11
|
+
private seq;
|
|
12
|
+
private readonly abort;
|
|
13
|
+
private steerFn;
|
|
14
|
+
private pending;
|
|
15
|
+
private finished;
|
|
16
|
+
constructor(sessionKey: string, agent: string, taskId: string, onUpdate: (snapshot: UniversalSnapshot, seq: number) => void, interactionHandler?: InteractionHandler | undefined);
|
|
17
|
+
get currentSeq(): number;
|
|
18
|
+
private publish;
|
|
19
|
+
run(driver: AgentDriver, input: AgentTurnInput, prompt: string, model: string | null, effort: string | null): Promise<DriverResult>;
|
|
20
|
+
private applyEvent;
|
|
21
|
+
private askUser;
|
|
22
|
+
private flushPending;
|
|
23
|
+
stop(): void;
|
|
24
|
+
steer(prompt: string, attachments?: string[]): Promise<boolean>;
|
|
25
|
+
interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function projectActivity(snap: UniversalSnapshot): string;
|