@pikiloom/kernel 0.1.4 → 0.2.1

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
@@ -275,6 +275,40 @@ plugins are the composable per-capability layer on top.)
275
275
 
276
276
  ---
277
277
 
278
+ ## Workspace: unified directory + session / skill / mcp management
279
+
280
+ One explicitly-configurable **top-level directory** (`createLoom({ stateDirName })`, default
281
+ `'pikiloom'` → `.pikiloom`) gives a consuming app the same "everything under one folder" model
282
+ pikiloom uses, resolved in two scopes — global (`~/.pikiloom`) and per-workspace
283
+ (`<workdir>/.pikiloom`). It's exposed off the `Loom`:
284
+
285
+ ```ts
286
+ const loom = createLoom({ drivers: [new ClaudeDriver(), new CodexDriver()], stateDirName: 'pikiloom' });
287
+
288
+ // Unified, searchable session list — the kernel's MANAGED sessions (scoped per workspace by the
289
+ // cwd they ran in) MERGED with each agent's OWN native sessions (claude/codex read their on-disk
290
+ // transcripts). Global view, per-workspace view, and search — all kernel-owned:
291
+ await loom.sessions.list({ scope: 'workspace', workdir }); // this folder (managed + native)
292
+ await loom.sessions.list({ scope: 'global' }); // every workspace's managed sessions
293
+ await loom.sessions.search({ query: 'deploy', workdir });
294
+
295
+ // Skills registry: one canonical dir, symlinked into every agent's skills dir.
296
+ loom.skills.ensureLinks('workspace', workdir); // <wd>/.claude/skills + .agents/skills → <wd>/.pikiloom/skills
297
+ loom.skills.list({ workdir }); // installed skills (workspace + global)
298
+ await loom.skills.search('pdf'); // installable skills (npm)
299
+
300
+ // MCP catalog + discovery (enabling a server stays on the Plugin.tools()/ToolProvider seam):
301
+ loom.mcp.recommended(); // curated catalog
302
+ await loom.mcp.search('postgres'); // MCP registry → npm
303
+
304
+ loom.paths.skillsDir('global'); // ~/.pikiloom/skills, etc.
305
+ ```
306
+
307
+ A driver opts into native discovery by implementing
308
+ `listNativeSessions?({ workdir, limit }): NativeSessionInfo[]` (Claude/Codex/Gemini do; the pure
309
+ readers are also exported as `discover{Claude,Codex,Gemini}NativeSessions`). All of this is
310
+ node-builtins-only and additive — every existing port/default is unchanged.
311
+
278
312
  ## Exports
279
313
 
280
314
  Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel/drivers`,
@@ -284,6 +318,7 @@ Main entry `@pikiloom/kernel` re-exports everything. Subpaths: `@pikiloom/kernel
284
318
  - Drivers: `EchoDriver`, `ClaudeDriver`, `CodexDriver`, `GeminiDriver`, `HermesDriver`
285
319
  - Surfaces: `WebSurface`, `CliSurface`
286
320
  - Ports/defaults: `FsSessionStore`, `NullModelResolver`, `NoopToolProvider`, `PassthroughSystemPromptBuilder`, `AutoCancelInteractionHandler`, `DeferToTerminalInteractionHandler`, `NoopCatalog`, `defaultBaseDir`
321
+ - Workspace: `resolveLoomPaths`, `SessionsManager`, `SkillsManager`, `McpRegistry`, `ensureDirSymlink`, `discover{Claude,Codex,Gemini}NativeSessions` + types `LoomPaths`, `LoomScope`, `ManagedSessionInfo`, `NativeSessionInfo`, `SkillInfo`, `McpCatalogEntry`
287
322
  - Protocol: `UniversalSnapshot`, `diffSnapshot`, `applySnapshotPatch`, `emptySnapshot`, `PROTOCOL_VERSION`, all wire/`Client*`/`Server*` message types
288
323
  - Types: `AgentDriver`, `AgentTurnInput`, `DriverContext`, `DriverEvent`, `DriverResult`, `LoomIO`, `PromptInput`, `Surface`, `Plugin`, `SpawnContribution`, `SessionStore`, `ModelResolver`, `ToolProvider`, `SystemPromptBuilder`, `InteractionHandler`, `Catalog`, …
289
324
 
@@ -82,6 +82,17 @@ export interface TuiSpec {
82
82
  cwd: string;
83
83
  env?: Record<string, string>;
84
84
  }
85
+ export interface NativeSessionInfo {
86
+ sessionId: string;
87
+ title: string | null;
88
+ preview: string | null;
89
+ cwd: string | null;
90
+ model: string | null;
91
+ createdAt: string | null;
92
+ updatedAt: string | null;
93
+ running: boolean;
94
+ messageCount?: number | null;
95
+ }
85
96
  export interface AgentDriver {
86
97
  readonly id: string;
87
98
  readonly capabilities?: {
@@ -92,4 +103,8 @@ export interface AgentDriver {
92
103
  };
93
104
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
94
105
  tui?(input: TuiInput): TuiSpec;
106
+ listNativeSessions?(opts: {
107
+ workdir: string;
108
+ limit?: number;
109
+ }): NativeSessionInfo[] | Promise<NativeSessionInfo[]>;
95
110
  }
@@ -5,9 +5,11 @@ export interface CoreSessionRecord {
5
5
  sessionId: string;
6
6
  nativeSessionId?: string | null;
7
7
  workspacePath: string;
8
+ workdir?: string | null;
8
9
  createdAt: string;
9
10
  updatedAt: string;
10
11
  title?: string | null;
12
+ preview?: string | null;
11
13
  model?: string | null;
12
14
  effort?: string | null;
13
15
  runState?: 'running' | 'completed' | 'incomplete';
@@ -1,4 +1,4 @@
1
- import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec } from '../contracts/driver.js';
1
+ import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
2
  import type { UniversalUsage, UniversalPlan } from '../protocol/index.js';
3
3
  export declare class ClaudeDriver implements AgentDriver {
4
4
  private readonly bin;
@@ -11,6 +11,10 @@ export declare class ClaudeDriver implements AgentDriver {
11
11
  };
12
12
  constructor(bin?: string);
13
13
  tui(input: TuiInput): TuiSpec;
14
+ listNativeSessions(opts: {
15
+ workdir: string;
16
+ limit?: number;
17
+ }): NativeSessionInfo[];
14
18
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
15
19
  private usage;
16
20
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { discoverClaudeNativeSessions } from '../workspace/native.js';
2
3
  // Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
3
4
  // events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
4
5
  // (system / stream_event{message_start,content_block_delta,message_delta} / assistant / result),
@@ -22,6 +23,9 @@ export class ClaudeDriver {
22
23
  args.push(...input.extraArgs);
23
24
  return { command: this.bin, args, cwd: input.workdir, env: input.env };
24
25
  }
26
+ listNativeSessions(opts) {
27
+ return discoverClaudeNativeSessions(opts.workdir, { limit: opts.limit });
28
+ }
25
29
  run(input, ctx) {
26
30
  const steerable = !!input.steerable;
27
31
  const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
@@ -232,6 +236,23 @@ export function handleClaudeEvent(ev, s, emit) {
232
236
  s.cacheCreation = u?.cache_creation_input_tokens ?? 0;
233
237
  s.output = 0;
234
238
  }
239
+ else if (inner.type === 'content_block_start') {
240
+ // Claude emits multiple text/thinking blocks per turn (one set per tool-use round). Insert a
241
+ // paragraph break before a NEW block when prior content exists, so the live preview shows
242
+ // breaks between segments instead of running them together. Mirrors the legacy driver; the
243
+ // separator is emitted as a delta so the runtime's accumulated snapshot stays in sync with s.
244
+ const bt = inner.content_block?.type;
245
+ if (bt === 'text' && s.text && !s.text.endsWith('\n\n')) {
246
+ const sep = s.text.endsWith('\n') ? '\n' : '\n\n';
247
+ s.text += sep;
248
+ emit({ type: 'text', delta: sep });
249
+ }
250
+ else if (bt === 'thinking' && s.reasoning && !s.reasoning.endsWith('\n\n')) {
251
+ const sep = s.reasoning.endsWith('\n') ? '\n' : '\n\n';
252
+ s.reasoning += sep;
253
+ emit({ type: 'reasoning', delta: sep });
254
+ }
255
+ }
235
256
  else if (inner.type === 'content_block_delta') {
236
257
  const d = inner.delta || {};
237
258
  if (d.type === 'text_delta' && d.text) {
@@ -1,5 +1,22 @@
1
- import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, TuiInput, TuiSpec } from '../contracts/driver.js';
1
+ import type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
2
  import type { UniversalUsage } from '../protocol/index.js';
3
+ export declare function codexToolSummary(item: any): {
4
+ id: string;
5
+ name: string;
6
+ summary: string;
7
+ } | null;
8
+ export interface CodexContentState {
9
+ text: string;
10
+ reasoning: string;
11
+ streamedReasoning: boolean;
12
+ msgs: string[];
13
+ thinkParts: string[];
14
+ }
15
+ export declare function codexReasoningItemText(item: any): string;
16
+ export declare function captureCodexAgentMessage(item: any, s: CodexContentState, deltaItems: Set<string>, phases: Map<string, string>, emit: (e: DriverEvent) => void): void;
17
+ export declare function captureCodexReasoning(text: string, s: CodexContentState, emit: (e: DriverEvent) => void): void;
18
+ export declare function codexFinalText(s: CodexContentState): string;
19
+ export declare function codexFinalReasoning(s: CodexContentState): string;
3
20
  export declare class CodexDriver implements AgentDriver {
4
21
  private readonly bin;
5
22
  readonly id = "codex";
@@ -12,6 +29,10 @@ export declare class CodexDriver implements AgentDriver {
12
29
  constructor(bin?: string);
13
30
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
14
31
  tui(input: TuiInput): TuiSpec;
32
+ listNativeSessions(opts: {
33
+ workdir: string;
34
+ limit?: number;
35
+ }): NativeSessionInfo[];
15
36
  }
16
37
  export interface CodexUsageState {
17
38
  input: number | null;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { discoverCodexNativeSessions } from '../workspace/native.js';
2
3
  // Minimal newline-delimited JSON-RPC client for `codex app-server` (ported faithfully
3
4
  // from pikiloom's CodexAppServer; trimmed to what the kernel needs).
4
5
  class AppServer {
@@ -20,7 +21,12 @@ class AppServer {
20
21
  onServerRequest(cb) { this.requestResponder = cb; }
21
22
  async start() {
22
23
  const args = ['app-server'];
23
- const overrides = this.configOverrides.some(c => /^features\.goals\s*=/.test(c)) ? this.configOverrides : [...this.configOverrides, 'features.goals=true'];
24
+ // Do NOT force model_reasoning_summary codex reasoning summaries stay OFF by default
25
+ // (respecting ~/.codex/config.toml, which is the original behavior). A caller that wants
26
+ // thinking can pass `model_reasoning_summary=...` via configOverrides; we never inject it.
27
+ const overrides = [...this.configOverrides];
28
+ if (!overrides.some(c => /^features\.goals\s*=/.test(c)))
29
+ overrides.push('features.goals=true');
24
30
  for (const c of overrides)
25
31
  args.push('-c', c);
26
32
  try {
@@ -126,7 +132,12 @@ function codexFileChangeSummary(item) {
126
132
  }
127
133
  // Normalize a codex app-server item -> {id, name, summary}. The summary mirrors pikiloom's
128
134
  // vocabulary ("Run shell: <cmd>", "Edit <path>") so the activity projection reads naturally.
129
- function codexToolSummary(item) {
135
+ // Only ACTUAL tool calls become Activity rows. agentMessage (the answer text) and reasoning
136
+ // (thinking) are CONTENT — they render below as message text / thinking, never as tools. The old
137
+ // fallback to `item.type` wrongly turned every item (incl. agentMessage/reasoning) into a bogus
138
+ // "tool" in the Activity card. Mirrors the legacy driver's isCodexToolCallItem whitelist.
139
+ const CODEX_TOOL_CALL_TYPES = new Set(['dynamicToolCall', 'mcpToolCall', 'collabAgentToolCall']);
140
+ export function codexToolSummary(item) {
130
141
  const id = String(item?.id || '');
131
142
  if (!id)
132
143
  return null;
@@ -136,8 +147,13 @@ function codexToolSummary(item) {
136
147
  }
137
148
  if (item.type === 'fileChange' || item.type === 'patch')
138
149
  return { id, name: 'edit', summary: codexFileChangeSummary(item) };
139
- const tool = typeof item?.tool === 'string' ? item.tool : typeof item?.name === 'string' ? item.name : item?.type;
140
- return tool ? { id, name: String(tool), summary: String(tool) } : null;
150
+ if (CODEX_TOOL_CALL_TYPES.has(item.type)) {
151
+ const raw = typeof item.tool === 'string' && item.tool.trim() ? item.tool.trim()
152
+ : typeof item.name === 'string' && item.name.trim() ? item.name.trim() : '';
153
+ const name = raw ? (raw.split('.').pop() || raw) : 'tool';
154
+ return { id, name, summary: raw ? `Use ${name}` : 'Use tool' };
155
+ }
156
+ return null;
141
157
  }
142
158
  // codex `item/tool/requestUserInput` params -> a normalized UniversalInteraction
143
159
  // (faithful to the legacy codex driver's toAgentInteraction shape).
@@ -160,6 +176,51 @@ function codexUserInputToInteraction(params, promptId) {
160
176
  return null;
161
177
  return { promptId, kind: 'user-input', title: 'User Input Required', hint: 'Use the buttons when available, or reply with text.', questions };
162
178
  }
179
+ // Reasoning text from a completed `reasoning` item (item/completed or rawResponseItem/completed):
180
+ // summary/content arrays of plain strings or {text} objects.
181
+ export function codexReasoningItemText(item) {
182
+ const parts = [
183
+ ...(Array.isArray(item?.summary) ? item.summary : []),
184
+ ...(Array.isArray(item?.content) ? item.content : []),
185
+ ];
186
+ return parts.map((p) => (typeof p === 'string' ? p : p?.text || '')).filter(Boolean).join('\n').trim();
187
+ }
188
+ // A completed final_answer agentMessage that did NOT stream deltas: append + emit it live.
189
+ // deltaItems holds the ids already streamed, so a completed item echoing a streamed one is
190
+ // not double-counted (matches the legacy driver's deltaSeenForItem guard).
191
+ export function captureCodexAgentMessage(item, s, deltaItems, phases, emit) {
192
+ const phase = item?.phase || (item?.id ? phases.get(item.id) : null) || 'final_answer';
193
+ if (phase !== 'final_answer')
194
+ return;
195
+ const text = typeof item?.text === 'string' ? item.text.trim() : '';
196
+ if (!text)
197
+ return;
198
+ s.msgs.push(text);
199
+ if (item.id && deltaItems.has(item.id))
200
+ return;
201
+ const delta = s.text.trim() ? `\n\n${text}` : text;
202
+ s.text += delta;
203
+ emit({ type: 'text', delta });
204
+ }
205
+ // A completed reasoning item: keep it for the end-of-turn fallback, and stream it live only
206
+ // when nothing arrived as reasoning deltas (so the streamed path is never double-emitted).
207
+ export function captureCodexReasoning(text, s, emit) {
208
+ if (!text)
209
+ return;
210
+ s.thinkParts.push(text);
211
+ if (s.streamedReasoning)
212
+ return;
213
+ const delta = s.reasoning.trim() ? `\n\n${text}` : text;
214
+ s.reasoning += delta;
215
+ emit({ type: 'reasoning', delta });
216
+ }
217
+ // End-of-turn finalizers: prefer streamed text/reasoning, fall back to completed-item parts.
218
+ export function codexFinalText(s) {
219
+ return s.text.trim() ? s.text : s.msgs.join('\n\n');
220
+ }
221
+ export function codexFinalReasoning(s) {
222
+ return s.reasoning.trim() ? s.reasoning : s.thinkParts.join('\n\n');
223
+ }
163
224
  export class CodexDriver {
164
225
  bin;
165
226
  id = 'codex';
@@ -173,9 +234,10 @@ export class CodexDriver {
173
234
  // to the native account.
174
235
  const config = [...(input.configOverrides || [])];
175
236
  const srv = new AppServer(this.bin, config, input.env);
176
- const state = { text: '', reasoning: '', sessionId: input.sessionId ?? null, input: null, output: null, cached: null, contextUsed: null, contextWindow: null, status: null, error: null, turnId: null };
237
+ const state = { text: '', reasoning: '', streamedReasoning: false, msgs: [], thinkParts: [], sessionId: input.sessionId ?? null, input: null, output: null, cached: null, contextUsed: null, contextWindow: null, status: null, error: null, turnId: null };
177
238
  const phases = new Map();
178
239
  const toolSummaries = new Map();
240
+ const deltaItems = new Set();
179
241
  let steerRegistered = false;
180
242
  const ok = await srv.start();
181
243
  if (!ok)
@@ -239,6 +301,8 @@ export class CodexDriver {
239
301
  const phase = params?.itemId ? (phases.get(params.itemId) || 'final_answer') : 'final_answer';
240
302
  if (phase === 'final_answer' && params?.delta) {
241
303
  state.text += params.delta;
304
+ if (params.itemId)
305
+ deltaItems.add(params.itemId);
242
306
  ctx.emit({ type: 'text', delta: params.delta });
243
307
  }
244
308
  break;
@@ -247,16 +311,28 @@ export class CodexDriver {
247
311
  case 'item/reasoning/summaryTextDelta':
248
312
  if (params?.delta) {
249
313
  state.reasoning += params.delta;
314
+ state.streamedReasoning = true;
250
315
  ctx.emit({ type: 'reasoning', delta: params.delta });
251
316
  }
252
317
  break;
253
318
  case 'item/completed': {
254
319
  const item = params?.item || {};
320
+ // Final answer / reasoning delivered as a completed item (no preceding deltas).
321
+ if (item.type === 'agentMessage')
322
+ captureCodexAgentMessage(item, state, deltaItems, phases, ctx.emit);
323
+ else if (item.type === 'reasoning')
324
+ captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
255
325
  const t = codexToolSummary(item);
256
326
  if (t && toolSummaries.has(t.id))
257
327
  ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary: toolSummaries.get(t.id) || t.summary, status: item.status === 'failed' ? 'failed' : 'done' } });
258
328
  break;
259
329
  }
330
+ case 'rawResponseItem/completed': {
331
+ const item = params?.item || {};
332
+ if (item?.type === 'reasoning')
333
+ captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
334
+ break;
335
+ }
260
336
  case 'turn/plan/updated': {
261
337
  const plan = planFromUpdate(params);
262
338
  if (plan)
@@ -307,10 +383,11 @@ export class CodexDriver {
307
383
  await turnDone;
308
384
  const usage = codexUsageOf(state);
309
385
  const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
386
+ const finalReasoning = codexFinalReasoning(state);
310
387
  return {
311
388
  ok: ok2,
312
- text: state.text,
313
- reasoning: state.reasoning || undefined,
389
+ text: codexFinalText(state),
390
+ reasoning: finalReasoning || undefined,
314
391
  error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
315
392
  stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
316
393
  sessionId: state.sessionId,
@@ -329,6 +406,9 @@ export class CodexDriver {
329
406
  args.push(...input.extraArgs);
330
407
  return { command: this.bin, args, cwd: input.workdir, env: input.env };
331
408
  }
409
+ listNativeSessions(opts) {
410
+ return discoverCodexNativeSessions(opts.workdir, { limit: opts.limit });
411
+ }
332
412
  }
333
413
  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
334
414
  function buildTurnInput(prompt, attachments) {
@@ -1,4 +1,4 @@
1
- import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec } from '../contracts/driver.js';
1
+ import type { AgentDriver, AgentTurnInput, DriverContext, DriverResult, DriverEvent, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
2
  export declare class GeminiDriver implements AgentDriver {
3
3
  private readonly bin;
4
4
  readonly id = "gemini";
@@ -11,6 +11,10 @@ export declare class GeminiDriver implements AgentDriver {
11
11
  constructor(bin?: string);
12
12
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
13
13
  tui(input: TuiInput): TuiSpec;
14
+ listNativeSessions(opts: {
15
+ workdir: string;
16
+ limit?: number;
17
+ }): NativeSessionInfo[];
14
18
  }
15
19
  export declare function parseGeminiEvent(ev: any, s: any, tools: Map<string, {
16
20
  name: string;
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { discoverGeminiNativeSessions } from '../workspace/native.js';
2
3
  // Native kernel Gemini driver: `gemini --output-format stream-json ... -p <prompt>` and
3
4
  // parse its stream-json events into kernel DriverEvents. Faithful to pikiloom's geminiParse.
4
5
  export class GeminiDriver {
@@ -80,6 +81,9 @@ export class GeminiDriver {
80
81
  args.push(...input.extraArgs);
81
82
  return { command: this.bin, args, cwd: input.workdir, env: input.env };
82
83
  }
84
+ listNativeSessions(opts) {
85
+ return discoverGeminiNativeSessions(opts.workdir, { limit: opts.limit });
86
+ }
83
87
  }
84
88
  export function parseGeminiEvent(ev, s, tools, emit) {
85
89
  const t = ev.type || '';
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { SessionRunner } from './runtime/session-runner.js';
4
4
  export { runTurn, type RunTurnOptions, type TurnOutcome } from './runtime/turn.js';
5
5
  export { PtyBridge, ptyAvailable, type PtyExit, type PtyOpenOpts } from './runtime/pty.js';
6
6
  export { attachTui, type AttachTuiOptions } from './runtime/tui.js';
7
- export type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, SteerFn, McpServerSpec, TuiInput, TuiSpec, } from './contracts/driver.js';
7
+ export type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, SteerFn, McpServerSpec, TuiInput, TuiSpec, NativeSessionInfo, } from './contracts/driver.js';
8
8
  export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, ToolProvider, SystemPromptBuilder, InteractionHandler, Catalog, } from './contracts/ports.js';
9
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';
@@ -15,4 +15,5 @@ export { GeminiDriver } from './drivers/gemini.js';
15
15
  export { HermesDriver } from './drivers/hermes.js';
16
16
  export { WebSurface, type WebSurfaceOptions } from './surfaces/web.js';
17
17
  export { CliSurface } from './surfaces/cli.js';
18
+ export * from './workspace/index.js';
18
19
  export * from './protocol/index.js';
package/dist/index.js CHANGED
@@ -25,5 +25,7 @@ export { GeminiDriver } from './drivers/gemini.js';
25
25
  export { HermesDriver } from './drivers/hermes.js';
26
26
  export { WebSurface } from './surfaces/web.js';
27
27
  export { CliSurface } from './surfaces/cli.js';
28
+ // Workspace: unified top-level directory + session/skill/mcp management (loom.paths/sessions/skills/mcp)
29
+ export * from './workspace/index.js';
28
30
  // Protocol (the wire vocabulary; shared with transports)
29
31
  export * from './protocol/index.js';
@@ -25,11 +25,15 @@ export class FsSessionStore {
25
25
  if (!existing) {
26
26
  const now = new Date().toISOString();
27
27
  await this.save({
28
- agent, sessionId, workspacePath,
28
+ agent, sessionId, workspacePath, workdir: path.resolve(opts.workdir),
29
29
  createdAt: now, updatedAt: now,
30
30
  title: opts.title ?? null, runState: 'running', runDetail: null,
31
31
  });
32
32
  }
33
+ else if (!existing.workdir) {
34
+ existing.workdir = path.resolve(opts.workdir);
35
+ await this.save(existing);
36
+ }
33
37
  return { sessionId, workspacePath };
34
38
  }
35
39
  async get(agent, sessionId) {
@@ -74,6 +78,8 @@ export class FsSessionStore {
74
78
  rec.nativeSessionId = result.sessionId;
75
79
  if (result.text && !rec.title)
76
80
  rec.title = result.text.slice(0, 80);
81
+ if (result.text)
82
+ rec.preview = result.text.replace(/\s+/g, ' ').trim().slice(0, 200) || rec.preview || null;
77
83
  await this.save(rec);
78
84
  }
79
85
  async appendTurn(agent, sessionId, turn) {
@@ -2,6 +2,10 @@ import type { AgentDriver, TuiSpec } from '../contracts/driver.js';
2
2
  import type { SessionStore, ModelResolver, ToolProvider, SystemPromptBuilder, Catalog, InteractionHandler } from '../contracts/ports.js';
3
3
  import type { LoomIO, Surface, Plugin } from '../contracts/surface.js';
4
4
  import { PtyBridge, type PtyOpenOpts, type PtyExit } from './pty.js';
5
+ import { type LoomPaths } from '../workspace/paths.js';
6
+ import { SessionsManager } from '../workspace/sessions.js';
7
+ import { SkillsManager } from '../workspace/skills.js';
8
+ import { McpRegistry } from '../workspace/mcp.js';
5
9
  export interface TuiLaunchOptions {
6
10
  agent?: string;
7
11
  workdir?: string;
@@ -9,9 +13,11 @@ export interface TuiLaunchOptions {
9
13
  sessionId?: string | null;
10
14
  }
11
15
  export interface LoomConfig {
16
+ stateDirName?: string;
12
17
  appNamespace?: string;
13
18
  workdir?: string;
14
19
  defaultAgent?: string;
20
+ agentSkillDirs?: string[];
15
21
  drivers?: AgentDriver[];
16
22
  surfaces?: Surface[];
17
23
  plugins?: Plugin[];
@@ -27,6 +33,10 @@ export interface LoomConfig {
27
33
  }
28
34
  export interface Loom {
29
35
  readonly io: LoomIO;
36
+ readonly paths: LoomPaths;
37
+ readonly sessions: SessionsManager;
38
+ readonly skills: SkillsManager;
39
+ readonly mcp: McpRegistry;
30
40
  registerDriver(driver: AgentDriver): void;
31
41
  registerPlugin(plugin: Plugin): void;
32
42
  start(): Promise<void>;
@@ -2,8 +2,15 @@ import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemP
2
2
  import { Hub } from './hub.js';
3
3
  import { PtyBridge } from './pty.js';
4
4
  import { attachTui } from './tui.js';
5
+ import { resolveLoomPaths } from '../workspace/paths.js';
6
+ import { SessionsManager } from '../workspace/sessions.js';
7
+ import { SkillsManager } from '../workspace/skills.js';
8
+ import { McpRegistry } from '../workspace/mcp.js';
5
9
  export function createLoom(config = {}) {
6
- const appNamespace = config.appNamespace || 'loom';
10
+ // The top-level dir defaults to 'pikiloom'; appNamespace (the default session-store base)
11
+ // falls back to it so a single knob configures everything for a fresh consumer.
12
+ const stateDirName = config.stateDirName || config.appNamespace || 'pikiloom';
13
+ const appNamespace = config.appNamespace || stateDirName;
7
14
  const workdir = config.workdir || process.cwd();
8
15
  const log = config.log || (() => { });
9
16
  const drivers = new Map();
@@ -12,11 +19,16 @@ export function createLoom(config = {}) {
12
19
  const defaultAgent = config.defaultAgent || config.drivers?.[0]?.id || 'echo';
13
20
  // Held as a live reference so registerPlugin() mutates the same array the Hub iterates.
14
21
  const plugins = [...(config.plugins || [])];
22
+ const sessionStore = config.sessionStore || new FsSessionStore(defaultBaseDir(appNamespace));
23
+ const paths = resolveLoomPaths({ stateDirName });
24
+ const sessions = new SessionsManager({ store: sessionStore, drivers: () => drivers, defaultWorkdir: workdir, log });
25
+ const skills = new SkillsManager({ paths, agentSkillDirs: config.agentSkillDirs, log });
26
+ const mcp = new McpRegistry({ log });
15
27
  const hub = new Hub({
16
28
  drivers,
17
29
  defaultAgent,
18
30
  workdir,
19
- sessionStore: config.sessionStore || new FsSessionStore(defaultBaseDir(appNamespace)),
31
+ sessionStore,
20
32
  modelResolver: config.modelResolver || new NullModelResolver(),
21
33
  toolProvider: config.toolProvider || new NoopToolProvider(),
22
34
  systemPromptBuilder: config.systemPromptBuilder || new PassthroughSystemPromptBuilder(),
@@ -37,6 +49,10 @@ export function createLoom(config = {}) {
37
49
  };
38
50
  return {
39
51
  io: hub,
52
+ paths,
53
+ sessions,
54
+ skills,
55
+ mcp,
40
56
  registerDriver(driver) { drivers.set(driver.id, driver); },
41
57
  registerPlugin(plugin) { plugins.push(plugin); },
42
58
  async start() {
@@ -0,0 +1,5 @@
1
+ export { resolveLoomPaths, normalizeStateDirName, type LoomPaths, type LoomScope } from './paths.js';
2
+ export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, NATIVE_SESSION_RUNNING_THRESHOLD_MS, type DiscoverOptions, type NativeSessionInfo, } from './native.js';
3
+ export { SessionsManager, type ManagedSessionInfo, type SessionSource, type ListSessionsOptions, type SearchSessionsOptions, type SessionsManagerDeps, } from './sessions.js';
4
+ export { SkillsManager, ensureDirSymlink, type SkillInfo, type SkillSearchResult, type SkillsManagerOptions, } from './skills.js';
5
+ export { McpRegistry, type McpCatalogEntry, type McpSearchResult, type McpRegistryOptions, } from './mcp.js';
@@ -0,0 +1,7 @@
1
+ // Workspace subsystem: the unified top-level directory + session/skill/mcp management that
2
+ // a consuming app gets "for free" off createLoom() (exposed as loom.paths/sessions/skills/mcp).
3
+ export { resolveLoomPaths, normalizeStateDirName } from './paths.js';
4
+ export { discoverClaudeNativeSessions, discoverCodexNativeSessions, discoverGeminiNativeSessions, encodeClaudeProjectDir, NATIVE_SESSION_RUNNING_THRESHOLD_MS, } from './native.js';
5
+ export { SessionsManager, } from './sessions.js';
6
+ export { SkillsManager, ensureDirSymlink, } from './skills.js';
7
+ export { McpRegistry, } from './mcp.js';
@@ -0,0 +1,44 @@
1
+ import type { McpServerSpec } from '../contracts/driver.js';
2
+ export interface McpCatalogEntry {
3
+ id: string;
4
+ name: string;
5
+ description?: string;
6
+ category?: string;
7
+ brand?: string;
8
+ transport: {
9
+ type: 'stdio';
10
+ command: string;
11
+ args?: string[];
12
+ } | {
13
+ type: 'http';
14
+ url: string;
15
+ };
16
+ /** Required credential env var names (so a UI can prompt for them). */
17
+ envKeys?: string[];
18
+ homepage?: string;
19
+ }
20
+ export interface McpSearchResult {
21
+ name: string;
22
+ description: string | null;
23
+ source: 'registry' | 'npm';
24
+ npmPackage?: string | null;
25
+ homepage?: string | null;
26
+ }
27
+ export interface McpRegistryOptions {
28
+ /** Replace/extend the built-in recommended catalog. */
29
+ recommended?: McpCatalogEntry[];
30
+ fetchImpl?: typeof fetch;
31
+ log?: (msg: string) => void;
32
+ }
33
+ export declare class McpRegistry {
34
+ private readonly _recommended;
35
+ private readonly fetchImpl;
36
+ private readonly log?;
37
+ constructor(opts?: McpRegistryOptions);
38
+ /** The curated catalog of well-known servers. */
39
+ recommended(): McpCatalogEntry[];
40
+ /** Turn a catalog entry into a kernel McpServerSpec ready to hand to a driver/plugin. */
41
+ toServerSpec(entry: McpCatalogEntry, env?: Record<string, string>): McpServerSpec;
42
+ /** Search the public MCP registry, falling back to npm. Best-effort; [] on failure. */
43
+ search(query: string, limit?: number): Promise<McpSearchResult[]>;
44
+ }