pikiloom 0.4.39 → 0.4.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dashboard/dist/assets/{AgentTab-Ds8D6yrl.js → AgentTab-B4ZC9QFL.js} +1 -1
  2. package/dashboard/dist/assets/{ConnectionModal-Buj2d5L5.js → ConnectionModal-CAlACYKM.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BTv7rH8E.js → DirBrowser-D3KGIof1.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BnGwxS9U.js → ExtensionsTab-C_69Y7CR.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-DjQnpOmF.js → IMAccessTab-B5_6IS4X.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-DpuinsE3.js → Modal-BjZifpga.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-Bs842H3n.js → Modals-B_V24pNA.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Dvia29HZ.js → Select-D7xW38wq.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-BbrfUDLg.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-BzPtwDxq.js → SystemTab-i5nmYweA.js} +1 -1
  11. package/dashboard/dist/assets/{index-S0NmlDEH.js → index-A_dL4aEz.js} +13 -13
  12. package/dashboard/dist/assets/{index-yZ-iG1qk.js → index-D18pCeqv.js} +3 -3
  13. package/dashboard/dist/assets/{shared-p3kZpiD4.js → shared-BKwEgcmy.js} +1 -1
  14. package/dashboard/dist/index.html +1 -1
  15. package/dist/agent/drivers/codex.js +5 -3
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/stream.js +13 -1
  18. package/dist/bot/render-shared.js +2 -1
  19. package/dist/core/config/runtime-config.js +14 -22
  20. package/dist/dashboard/routes/agents.js +8 -4
  21. package/package.json +1 -1
  22. package/packages/kernel/README.md +35 -0
  23. package/packages/kernel/dist/contracts/driver.d.ts +15 -0
  24. package/packages/kernel/dist/contracts/ports.d.ts +2 -0
  25. package/packages/kernel/dist/drivers/claude.d.ts +5 -1
  26. package/packages/kernel/dist/drivers/claude.js +21 -0
  27. package/packages/kernel/dist/drivers/codex.d.ts +22 -1
  28. package/packages/kernel/dist/drivers/codex.js +87 -7
  29. package/packages/kernel/dist/drivers/gemini.d.ts +5 -1
  30. package/packages/kernel/dist/drivers/gemini.js +4 -0
  31. package/packages/kernel/dist/index.d.ts +2 -1
  32. package/packages/kernel/dist/index.js +2 -0
  33. package/packages/kernel/dist/ports/defaults.js +7 -1
  34. package/packages/kernel/dist/runtime/loom.d.ts +10 -0
  35. package/packages/kernel/dist/runtime/loom.js +18 -2
  36. package/packages/kernel/dist/workspace/index.d.ts +5 -0
  37. package/packages/kernel/dist/workspace/index.js +7 -0
  38. package/packages/kernel/dist/workspace/mcp.d.ts +44 -0
  39. package/packages/kernel/dist/workspace/mcp.js +82 -0
  40. package/packages/kernel/dist/workspace/native.d.ts +14 -0
  41. package/packages/kernel/dist/workspace/native.js +340 -0
  42. package/packages/kernel/dist/workspace/paths.d.ts +33 -0
  43. package/packages/kernel/dist/workspace/paths.js +40 -0
  44. package/packages/kernel/dist/workspace/sessions.d.ts +49 -0
  45. package/packages/kernel/dist/workspace/sessions.js +130 -0
  46. package/packages/kernel/dist/workspace/skills.d.ts +44 -0
  47. package/packages/kernel/dist/workspace/skills.js +173 -0
  48. package/dashboard/dist/assets/SessionPanel-CVItEgcH.js +0 -1
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ const BUILTIN_RECOMMENDED = [
2
+ { id: 'filesystem', name: 'Filesystem', description: 'Read/write files under allowed directories.', category: 'core', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
3
+ { id: 'github', name: 'GitHub', description: 'GitHub repos, issues, and PRs.', category: 'dev', brand: 'github', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, envKeys: ['GITHUB_PERSONAL_ACCESS_TOKEN'], homepage: 'https://github.com/modelcontextprotocol/servers' },
4
+ { id: 'fetch', name: 'Fetch', description: 'Fetch and convert web pages to markdown.', category: 'web', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
5
+ { id: 'git', name: 'Git', description: 'Local git repository operations.', category: 'dev', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-git'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
6
+ { id: 'memory', name: 'Memory', description: 'A knowledge-graph memory store.', category: 'core', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
7
+ { id: 'playwright', name: 'Playwright', description: 'Drive a real browser for automation.', category: 'web', brand: 'playwright', transport: { type: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp@latest'] }, homepage: 'https://github.com/microsoft/playwright-mcp' },
8
+ { id: 'context7', name: 'Context7', description: 'Up-to-date library docs & code examples.', category: 'docs', transport: { type: 'http', url: 'https://mcp.context7.com/mcp' }, homepage: 'https://context7.com' },
9
+ ];
10
+ export class McpRegistry {
11
+ _recommended;
12
+ fetchImpl;
13
+ log;
14
+ constructor(opts = {}) {
15
+ this._recommended = opts.recommended?.length ? opts.recommended : BUILTIN_RECOMMENDED;
16
+ this.fetchImpl = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined);
17
+ this.log = opts.log;
18
+ }
19
+ /** The curated catalog of well-known servers. */
20
+ recommended() {
21
+ return this._recommended.map(e => ({ ...e }));
22
+ }
23
+ /** Turn a catalog entry into a kernel McpServerSpec ready to hand to a driver/plugin. */
24
+ toServerSpec(entry, env) {
25
+ if (entry.transport.type === 'http') {
26
+ return { name: entry.id, type: 'http', url: entry.transport.url };
27
+ }
28
+ return { name: entry.id, type: 'stdio', command: entry.transport.command, args: entry.transport.args, env };
29
+ }
30
+ /** Search the public MCP registry, falling back to npm. Best-effort; [] on failure. */
31
+ async search(query, limit = 20) {
32
+ const q = (query || '').trim();
33
+ const n = Math.max(1, Math.min(50, limit));
34
+ const fetchImpl = this.fetchImpl;
35
+ if (!fetchImpl)
36
+ return [];
37
+ // 1) Official MCP registry.
38
+ try {
39
+ const url = `https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(q)}&limit=${n}`;
40
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
41
+ if (res.ok) {
42
+ const data = await res.json();
43
+ const servers = Array.isArray(data?.servers) ? data.servers : Array.isArray(data?.data) ? data.data : [];
44
+ const mapped = servers.map((s) => ({
45
+ name: String(s?.name ?? s?.id ?? ''),
46
+ description: s?.description ?? null,
47
+ source: 'registry',
48
+ npmPackage: s?.packages?.[0]?.identifier ?? s?.npm ?? null,
49
+ homepage: s?.repository?.url ?? s?.homepage ?? null,
50
+ })).filter(s => s.name);
51
+ if (mapped.length)
52
+ return mapped.slice(0, n);
53
+ }
54
+ }
55
+ catch (e) {
56
+ this.log?.(`[mcp] registry search failed: ${e?.message || e}`);
57
+ }
58
+ // 2) npm fallback.
59
+ try {
60
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`mcp server ${q}`.trim())}&size=${n}`;
61
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
62
+ if (!res.ok)
63
+ return [];
64
+ const data = await res.json();
65
+ const objects = Array.isArray(data?.objects) ? data.objects : [];
66
+ return objects.map((o) => {
67
+ const pkg = o?.package ?? {};
68
+ return {
69
+ name: String(pkg.name ?? ''),
70
+ description: pkg.description ?? null,
71
+ source: 'npm',
72
+ npmPackage: pkg.name ?? null,
73
+ homepage: pkg.links?.homepage ?? pkg.links?.npm ?? null,
74
+ };
75
+ }).filter(s => s.name).slice(0, n);
76
+ }
77
+ catch (e) {
78
+ this.log?.(`[mcp] npm search failed: ${e?.message || e}`);
79
+ return [];
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,14 @@
1
+ import type { NativeSessionInfo } from '../contracts/driver.js';
2
+ export type { NativeSessionInfo } from '../contracts/driver.js';
3
+ export interface DiscoverOptions {
4
+ home?: string;
5
+ limit?: number;
6
+ /** A session whose file changed within this window is treated as "running". */
7
+ runningThresholdMs?: number;
8
+ }
9
+ export declare const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10000;
10
+ /** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
11
+ export declare function encodeClaudeProjectDir(workdir: string): string;
12
+ export declare function discoverClaudeNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
13
+ export declare function discoverCodexNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
14
+ export declare function discoverGeminiNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
@@ -0,0 +1,340 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10_000;
5
+ function homeOf(opts) {
6
+ return opts?.home || os.homedir();
7
+ }
8
+ function cleanTitle(raw, max = 120) {
9
+ if (!raw)
10
+ return null;
11
+ const s = raw.replace(/\s+/g, ' ').trim();
12
+ if (!s)
13
+ return null;
14
+ return s.length <= max ? s : `${s.slice(0, max - 3).trimEnd()}...`;
15
+ }
16
+ function statSafe(p) {
17
+ try {
18
+ return fs.statSync(p);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ // ---- Claude: ~/.claude/projects/<encoded-workdir>/<sessionId>.jsonl ----
25
+ /** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
26
+ export function encodeClaudeProjectDir(workdir) {
27
+ return path.resolve(workdir).replace(/[^a-zA-Z0-9]/g, '-');
28
+ }
29
+ /** Extract plain text from a Claude `message.content` (string or block array). */
30
+ function claudeText(content) {
31
+ if (typeof content === 'string')
32
+ return content;
33
+ if (!Array.isArray(content))
34
+ return '';
35
+ const parts = [];
36
+ for (const block of content) {
37
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
38
+ parts.push(block.text);
39
+ }
40
+ }
41
+ return parts.join(' ');
42
+ }
43
+ function readClaudeHead(filePath, size) {
44
+ let title = null;
45
+ let model = null;
46
+ let lastUser = null;
47
+ let lastAssistant = null;
48
+ let turns = 0;
49
+ let head = '';
50
+ try {
51
+ const fd = fs.openSync(filePath, 'r');
52
+ const buf = Buffer.alloc(Math.min(256 * 1024, Math.max(65536, size)));
53
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
54
+ fs.closeSync(fd);
55
+ head = buf.toString('utf8', 0, n);
56
+ }
57
+ catch {
58
+ return { title: null, model: null, preview: null, turns: 0 };
59
+ }
60
+ for (const line of head.split('\n')) {
61
+ if (!line || line[0] !== '{')
62
+ continue;
63
+ let ev;
64
+ try {
65
+ ev = JSON.parse(line);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (ev.type === 'user' && ev.isMeta !== true) {
71
+ const text = claudeText(ev.message?.content).trim();
72
+ if (text && !text.startsWith('<') && !text.startsWith('[Image:')) {
73
+ if (!title)
74
+ title = cleanTitle(text);
75
+ lastUser = text;
76
+ turns++;
77
+ }
78
+ }
79
+ else if (ev.type === 'assistant') {
80
+ if (!model && ev.message?.model && ev.message.model !== '<synthetic>')
81
+ model = ev.message.model;
82
+ const text = claudeText(ev.message?.content).trim();
83
+ if (text)
84
+ lastAssistant = text;
85
+ }
86
+ }
87
+ const preview = cleanTitle(lastAssistant || lastUser, 200);
88
+ return { title, model, preview, turns };
89
+ }
90
+ export function discoverClaudeNativeSessions(workdir, opts = {}) {
91
+ const home = homeOf(opts);
92
+ const projectDir = path.join(home, '.claude', 'projects', encodeClaudeProjectDir(workdir));
93
+ let entries;
94
+ try {
95
+ entries = fs.readdirSync(projectDir, { withFileTypes: true });
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
101
+ const files = [];
102
+ for (const entry of entries) {
103
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
104
+ continue;
105
+ const filePath = path.join(projectDir, entry.name);
106
+ const stat = statSafe(filePath);
107
+ if (stat)
108
+ files.push({ sessionId: entry.name.slice(0, -6), filePath, stat });
109
+ }
110
+ files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
111
+ const selected = typeof opts.limit === 'number' ? files.slice(0, Math.max(0, opts.limit)) : files;
112
+ const now = Date.now();
113
+ return selected.map(({ sessionId, filePath, stat }) => {
114
+ const { title, model, preview, turns } = readClaudeHead(filePath, stat.size);
115
+ return {
116
+ sessionId,
117
+ title,
118
+ preview,
119
+ cwd: path.resolve(workdir),
120
+ model,
121
+ createdAt: stat.birthtime.toISOString(),
122
+ updatedAt: stat.mtime.toISOString(),
123
+ running: now - stat.mtimeMs < threshold,
124
+ messageCount: turns || null,
125
+ };
126
+ });
127
+ }
128
+ // ---- Codex: ~/.codex/sessions/**/rollout-*.jsonl (filtered by cwd) ----
129
+ function loadCodexTitleIndex(home) {
130
+ const indexPath = path.join(home, '.codex', 'session_index.jsonl');
131
+ const map = new Map();
132
+ let data;
133
+ try {
134
+ data = fs.readFileSync(indexPath, 'utf8');
135
+ }
136
+ catch {
137
+ return map;
138
+ }
139
+ for (const line of data.split('\n')) {
140
+ if (!line.trim())
141
+ continue;
142
+ try {
143
+ const entry = JSON.parse(line);
144
+ if (entry.id)
145
+ map.set(entry.id, { threadName: entry.thread_name || '', updatedAt: entry.updated_at || '' });
146
+ }
147
+ catch { /* skip */ }
148
+ }
149
+ return map;
150
+ }
151
+ function readCodexHead(filePath) {
152
+ try {
153
+ const fd = fs.openSync(filePath, 'r');
154
+ const buf = Buffer.alloc(8 * 1024);
155
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
156
+ fs.closeSync(fd);
157
+ const head = buf.toString('utf8', 0, n);
158
+ if (!head.includes('"session_meta"'))
159
+ return null;
160
+ const id = head.match(/"id"\s*:\s*"([^"]+)"/);
161
+ const cwd = head.match(/"cwd"\s*:\s*"([^"]+)"/);
162
+ const ts = head.match(/"timestamp"\s*:\s*"([^"]+)"/);
163
+ if (!id || !cwd)
164
+ return null;
165
+ return {
166
+ sessionId: id[1],
167
+ cwd: cwd[1],
168
+ timestamp: ts?.[1] || null,
169
+ isSubagent: /"source"\s*:\s*\{\s*"subagent"\s*:/.test(head) || /"thread_spawn"\s*:/.test(head),
170
+ };
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ export function discoverCodexNativeSessions(workdir, opts = {}) {
177
+ const home = homeOf(opts);
178
+ const sessionsDir = path.join(home, '.codex', 'sessions');
179
+ const resolvedWorkdir = path.resolve(workdir);
180
+ const titleIndex = loadCodexTitleIndex(home);
181
+ const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
182
+ const files = [];
183
+ const walk = (dir) => {
184
+ let entries;
185
+ try {
186
+ entries = fs.readdirSync(dir, { withFileTypes: true });
187
+ }
188
+ catch {
189
+ return;
190
+ }
191
+ for (const entry of entries) {
192
+ const full = path.join(dir, entry.name);
193
+ if (entry.isDirectory()) {
194
+ walk(full);
195
+ continue;
196
+ }
197
+ if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl'))
198
+ continue;
199
+ const stat = statSafe(full);
200
+ if (stat)
201
+ files.push({ filePath: full, stat });
202
+ }
203
+ };
204
+ walk(sessionsDir);
205
+ files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
206
+ const out = [];
207
+ const seen = new Set();
208
+ for (const { filePath, stat } of files) {
209
+ const meta = readCodexHead(filePath);
210
+ if (!meta || meta.isSubagent || path.resolve(meta.cwd) !== resolvedWorkdir)
211
+ continue;
212
+ if (seen.has(meta.sessionId))
213
+ continue;
214
+ seen.add(meta.sessionId);
215
+ const idx = titleIndex.get(meta.sessionId);
216
+ const updatedAt = idx?.updatedAt || stat.mtime.toISOString();
217
+ out.push({
218
+ sessionId: meta.sessionId,
219
+ title: cleanTitle(idx?.threadName || null),
220
+ preview: null,
221
+ cwd: meta.cwd,
222
+ model: null,
223
+ createdAt: meta.timestamp || stat.birthtime.toISOString(),
224
+ updatedAt,
225
+ running: Date.now() - Date.parse(updatedAt) < threshold,
226
+ messageCount: null,
227
+ });
228
+ if (typeof opts.limit === 'number' && out.length >= opts.limit)
229
+ break;
230
+ }
231
+ return out;
232
+ }
233
+ // ---- Gemini: ~/.gemini/projects.json -> tmp/<projectName>/chats/session-*.json[l] ----
234
+ function geminiProjectName(home, workdir) {
235
+ const projectsPath = path.join(home, '.gemini', 'projects.json');
236
+ try {
237
+ const data = JSON.parse(fs.readFileSync(projectsPath, 'utf8'));
238
+ const projects = data?.projects;
239
+ if (!projects || typeof projects !== 'object')
240
+ return null;
241
+ const resolved = path.resolve(workdir);
242
+ if (projects[resolved])
243
+ return projects[resolved];
244
+ for (const [dir, name] of Object.entries(projects)) {
245
+ if (path.resolve(dir) === resolved)
246
+ return name;
247
+ }
248
+ }
249
+ catch { /* none */ }
250
+ return null;
251
+ }
252
+ function geminiText(content) {
253
+ if (typeof content === 'string')
254
+ return content;
255
+ if (Array.isArray(content)) {
256
+ const parts = [];
257
+ for (const block of content) {
258
+ if (typeof block === 'string')
259
+ parts.push(block);
260
+ else if (block && typeof block === 'object' && typeof block.text === 'string')
261
+ parts.push(block.text);
262
+ }
263
+ return parts.join(' ');
264
+ }
265
+ return '';
266
+ }
267
+ export function discoverGeminiNativeSessions(workdir, opts = {}) {
268
+ const home = homeOf(opts);
269
+ const projectName = geminiProjectName(home, workdir);
270
+ if (!projectName)
271
+ return [];
272
+ const chatsDir = path.join(home, '.gemini', 'tmp', projectName, 'chats');
273
+ let entries;
274
+ try {
275
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
276
+ }
277
+ catch {
278
+ return [];
279
+ }
280
+ const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
281
+ const byId = new Map();
282
+ for (const entry of entries) {
283
+ if (!entry.isFile() || !entry.name.startsWith('session-'))
284
+ continue;
285
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
286
+ continue;
287
+ const filePath = path.join(chatsDir, entry.name);
288
+ const stat = statSafe(filePath);
289
+ if (!stat)
290
+ continue;
291
+ let data;
292
+ try {
293
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
294
+ }
295
+ catch {
296
+ continue;
297
+ }
298
+ const sessionId = data?.sessionId ? String(data.sessionId) : null;
299
+ if (!sessionId)
300
+ continue;
301
+ const messages = Array.isArray(data.messages) ? data.messages : [];
302
+ let title = null;
303
+ let lastUser = null;
304
+ let lastAssistant = null;
305
+ let turns = 0;
306
+ for (const msg of messages) {
307
+ if (msg?.type === 'user') {
308
+ const t = geminiText(msg.content).trim();
309
+ if (t) {
310
+ if (!title)
311
+ title = cleanTitle(t);
312
+ lastUser = t;
313
+ turns++;
314
+ }
315
+ }
316
+ else if (msg?.type === 'model' || msg?.type === 'assistant' || msg?.type === 'gemini') {
317
+ const t = geminiText(msg.content).trim();
318
+ if (t)
319
+ lastAssistant = t;
320
+ }
321
+ }
322
+ const updatedAt = data.lastUpdated || data.startTime || data.createdAt || stat.mtime.toISOString();
323
+ const existing = byId.get(sessionId);
324
+ if (existing && existing.updatedAt && updatedAt && Date.parse(updatedAt) <= Date.parse(existing.updatedAt))
325
+ continue;
326
+ byId.set(sessionId, {
327
+ sessionId,
328
+ title,
329
+ preview: cleanTitle(lastAssistant || lastUser, 200),
330
+ cwd: path.resolve(workdir),
331
+ model: null,
332
+ createdAt: data.startTime || data.createdAt || null,
333
+ updatedAt,
334
+ running: data.lastUpdated ? Date.now() - Date.parse(data.lastUpdated) < threshold : false,
335
+ messageCount: turns || null,
336
+ });
337
+ }
338
+ const out = [...byId.values()].sort((a, b) => Date.parse(b.updatedAt || '') - Date.parse(a.updatedAt || ''));
339
+ return typeof opts.limit === 'number' ? out.slice(0, Math.max(0, opts.limit)) : out;
340
+ }
@@ -0,0 +1,33 @@
1
+ export type LoomScope = 'global' | 'workspace';
2
+ export interface LoomPaths {
3
+ /** The bare name (no leading dot), e.g. 'pikiloom'. */
4
+ readonly stateDirName: string;
5
+ /** The dotted directory name, e.g. '.pikiloom'. */
6
+ readonly dirName: string;
7
+ readonly home: string;
8
+ /** ~/.<stateDirName> */
9
+ readonly globalRoot: string;
10
+ /** <workdir>/.<stateDirName> */
11
+ workspaceRoot(workdir: string): string;
12
+ /** The root for a scope (workspace requires a workdir). */
13
+ root(scope: LoomScope, workdir?: string): string;
14
+ /** <root>/sessions */
15
+ sessionsDir(scope: LoomScope, workdir?: string): string;
16
+ /** <root>/skills — the canonical skills registry that agent dirs symlink to. */
17
+ skillsDir(scope: LoomScope, workdir?: string): string;
18
+ /** <root>/mcp.json — the unified MCP server config for a scope. */
19
+ mcpConfigPath(scope: LoomScope, workdir?: string): string;
20
+ /**
21
+ * The base under which an agent keeps its own dotfiles for a scope:
22
+ * global -> ~ (so ~/.claude, ~/.agents)
23
+ * workspace -> <workdir> (so <workdir>/.claude, <workdir>/.agents)
24
+ * Used to resolve symlink targets for native agents.
25
+ */
26
+ agentHome(scope: LoomScope, workdir?: string): string;
27
+ }
28
+ /** Normalize a state dir name to its bare form: strip a leading dot, trim. */
29
+ export declare function normalizeStateDirName(name: string | null | undefined): string;
30
+ export declare function resolveLoomPaths(opts?: {
31
+ stateDirName?: string | null;
32
+ home?: string;
33
+ }): LoomPaths;