@pikiloom/kernel 0.3.5 → 0.3.7

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.
@@ -88,6 +88,7 @@ export interface NativeSessionInfo {
88
88
  preview: string | null;
89
89
  cwd: string | null;
90
90
  model: string | null;
91
+ effort?: string | null;
91
92
  createdAt: string | null;
92
93
  updatedAt: string | null;
93
94
  running: boolean;
@@ -1,9 +1,12 @@
1
1
  import type { AgentDriver, AgentTurnInput, DriverContext, DriverEvent, DriverResult, TuiInput, TuiSpec, NativeSessionInfo } from '../contracts/driver.js';
2
+ import type { UniversalToolCall } from '../protocol/index.js';
2
3
  export declare function codexToolSummary(item: any): {
3
4
  id: string;
4
5
  name: string;
5
6
  summary: string;
6
7
  } | null;
8
+ /** Project one app-server item into the kernel's rich, expandable tool-call shape. */
9
+ export declare function codexToolCall(item: any, fallbackStatus: UniversalToolCall['status']): UniversalToolCall | null;
7
10
  export interface CodexContentState {
8
11
  text: string;
9
12
  reasoning: string;
@@ -34,6 +34,89 @@ const CODEX_TOOL_CALL_TYPES = new Set(['dynamicToolCall', 'mcpToolCall', 'collab
34
34
  // Field-less placeholder summaries — a completed item with only one of these never overrides
35
35
  // a more specific summary cached at item/started.
36
36
  const CODEX_GENERIC_TOOL_SUMMARIES = new Set(['Search web', 'Run shell command', 'Edit files', 'Use tool']);
37
+ const CODEX_TOOL_INPUT_MAX = 4 * 1024;
38
+ const CODEX_TOOL_RESULT_MAX = 12 * 1024;
39
+ function codexToolText(value, max) {
40
+ if (value == null)
41
+ return null;
42
+ let text;
43
+ if (typeof value === 'string')
44
+ text = value;
45
+ else {
46
+ try {
47
+ text = JSON.stringify(value, null, 2);
48
+ }
49
+ catch {
50
+ text = String(value);
51
+ }
52
+ }
53
+ text = text.replace(/\r\n/g, '\n').trim();
54
+ if (!text)
55
+ return null;
56
+ return text.length <= max ? text : `${text.slice(0, max).trimEnd()}\n… (truncated)`;
57
+ }
58
+ function codexToolInput(item) {
59
+ switch (item?.type) {
60
+ case 'commandExecution': {
61
+ const command = Array.isArray(item.command) ? item.command.join(' ') : item.command;
62
+ if (!command)
63
+ return null;
64
+ const detail = { command };
65
+ if (typeof item.cwd === 'string' && item.cwd.trim())
66
+ detail.cwd = item.cwd;
67
+ return codexToolText(detail, CODEX_TOOL_INPUT_MAX);
68
+ }
69
+ case 'fileChange':
70
+ case 'patch':
71
+ return codexToolText(item.changes ?? item.patch ?? item.diff, CODEX_TOOL_INPUT_MAX);
72
+ case 'mcpToolCall':
73
+ case 'dynamicToolCall':
74
+ return codexToolText(item.arguments ?? item.input, CODEX_TOOL_INPUT_MAX);
75
+ case 'collabAgentToolCall':
76
+ return codexToolText({
77
+ prompt: item.prompt ?? undefined,
78
+ model: item.model ?? undefined,
79
+ reasoningEffort: item.reasoningEffort ?? item.reasoning_effort ?? undefined,
80
+ receiverThreadIds: item.receiverThreadIds ?? item.receiver_thread_ids ?? undefined,
81
+ }, CODEX_TOOL_INPUT_MAX);
82
+ case 'webSearch':
83
+ return codexToolText(item.action ?? item.query, CODEX_TOOL_INPUT_MAX);
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ function codexToolResult(item) {
89
+ if (item?.type === 'commandExecution') {
90
+ const output = codexToolText(item.aggregatedOutput ?? item.aggregated_output, CODEX_TOOL_RESULT_MAX);
91
+ const exitCode = item.exitCode ?? item.exit_code;
92
+ if (output && typeof exitCode === 'number' && exitCode !== 0)
93
+ return `${output}\n\n(exit code ${exitCode})`;
94
+ if (output)
95
+ return output;
96
+ return typeof exitCode === 'number' && exitCode !== 0 ? `exit code ${exitCode}` : null;
97
+ }
98
+ if (item?.type === 'mcpToolCall') {
99
+ const error = item.error?.message ?? item.error;
100
+ if (error)
101
+ return codexToolText(error, CODEX_TOOL_RESULT_MAX);
102
+ return codexToolText(item.result, CODEX_TOOL_RESULT_MAX);
103
+ }
104
+ if (item?.type === 'dynamicToolCall') {
105
+ return codexToolText(item.contentItems ?? item.content_items, CODEX_TOOL_RESULT_MAX);
106
+ }
107
+ if (item?.type === 'collabAgentToolCall') {
108
+ return codexToolText(item.agentsStates ?? item.agents_states, CODEX_TOOL_RESULT_MAX);
109
+ }
110
+ return null;
111
+ }
112
+ function codexToolStatus(item, fallback) {
113
+ const raw = String(item?.status || '').toLowerCase();
114
+ if (raw === 'failed' || raw === 'declined' || item?.success === false || item?.error)
115
+ return 'failed';
116
+ if (raw === 'completed' || raw === 'done' || item?.success === true)
117
+ return 'done';
118
+ return fallback;
119
+ }
37
120
  export function codexToolSummary(item) {
38
121
  const id = String(item?.id || '');
39
122
  if (!id)
@@ -66,6 +149,27 @@ export function codexToolSummary(item) {
66
149
  }
67
150
  return null;
68
151
  }
152
+ /** Project one app-server item into the kernel's rich, expandable tool-call shape. */
153
+ export function codexToolCall(item, fallbackStatus) {
154
+ const base = codexToolSummary(item);
155
+ if (!base)
156
+ return null;
157
+ return {
158
+ ...base,
159
+ input: codexToolInput(item),
160
+ result: codexToolResult(item),
161
+ status: codexToolStatus(item, fallbackStatus),
162
+ };
163
+ }
164
+ function appendCodexToolOutput(previous, delta) {
165
+ if (typeof delta !== 'string' || !delta)
166
+ return previous ?? null;
167
+ const next = `${previous ?? ''}${delta}`.replace(/\r\n/g, '\n');
168
+ if (next.length <= CODEX_TOOL_RESULT_MAX)
169
+ return next;
170
+ const marker = '… (earlier output truncated)\n';
171
+ return marker + next.slice(-(CODEX_TOOL_RESULT_MAX - marker.length));
172
+ }
69
173
  // codex `item/tool/requestUserInput` params -> a normalized UniversalInteraction
70
174
  // (faithful to the legacy codex driver's toAgentInteraction shape).
71
175
  function codexUserInputToInteraction(params, promptId) {
@@ -153,7 +257,7 @@ export class CodexDriver {
153
257
  const srv = new StdioRpcClient({ command: this.bin, args, env: input.env, label: 'codex app-server' });
154
258
  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 };
155
259
  const phases = new Map();
156
- const toolSummaries = new Map();
260
+ const toolCalls = new Map();
157
261
  const deltaItems = new Set();
158
262
  let lastTextItemId = null;
159
263
  let steerRegistered = false;
@@ -218,13 +322,35 @@ export class CodexDriver {
218
322
  const item = params?.item || {};
219
323
  if (item.type === 'agentMessage' && item.id)
220
324
  phases.set(item.id, item.phase || 'final_answer');
221
- const t = codexToolSummary(item);
222
- if (t && !toolSummaries.has(t.id)) {
223
- toolSummaries.set(t.id, t.summary);
224
- ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary: t.summary, status: 'running' } });
325
+ const call = codexToolCall(item, 'running');
326
+ if (call && !toolCalls.has(call.id)) {
327
+ toolCalls.set(call.id, call);
328
+ ctx.emit({ type: 'tool', call });
225
329
  }
226
330
  break;
227
331
  }
332
+ case 'item/commandExecution/outputDelta':
333
+ case 'item/fileChange/outputDelta': {
334
+ const id = String(params?.itemId || '');
335
+ const previous = toolCalls.get(id);
336
+ if (!previous)
337
+ break;
338
+ const call = { ...previous, result: appendCodexToolOutput(previous.result, params?.delta) };
339
+ toolCalls.set(id, call);
340
+ ctx.emit({ type: 'tool', call });
341
+ break;
342
+ }
343
+ case 'item/fileChange/patchUpdated': {
344
+ const id = String(params?.itemId || '');
345
+ const projected = codexToolCall({ type: 'fileChange', id, changes: params?.changes }, 'running');
346
+ if (!projected)
347
+ break;
348
+ const previous = toolCalls.get(id);
349
+ const call = previous ? { ...previous, ...projected, result: projected.result ?? previous.result } : projected;
350
+ toolCalls.set(id, call);
351
+ ctx.emit({ type: 'tool', call });
352
+ break;
353
+ }
228
354
  case 'item/agentMessage/delta': {
229
355
  if (!params?.delta)
230
356
  break;
@@ -259,14 +385,26 @@ export class CodexDriver {
259
385
  captureCodexAgentMessage(item, state, deltaItems, phases, ctx.emit);
260
386
  else if (item.type === 'reasoning')
261
387
  captureCodexReasoning(codexReasoningItemText(item), state, ctx.emit);
262
- const t = codexToolSummary(item);
263
- if (t) {
388
+ const projected = codexToolCall(item, 'done');
389
+ if (projected) {
264
390
  // Prefer the completed item's summary when it is specific — webSearch carries its
265
391
  // query only at completion (item/started may be query-less or absent entirely,
266
- // so no `toolSummaries.has` gate: a completed-only tool still gets its row).
267
- const summary = !CODEX_GENERIC_TOOL_SUMMARIES.has(t.summary) ? t.summary : (toolSummaries.get(t.id) || t.summary);
268
- toolSummaries.set(t.id, summary);
269
- ctx.emit({ type: 'tool', call: { id: t.id, name: t.name, summary, status: item.status === 'failed' ? 'failed' : 'done' } });
392
+ // so a completed-only tool still gets its row).
393
+ const previous = toolCalls.get(projected.id);
394
+ const summary = !CODEX_GENERIC_TOOL_SUMMARIES.has(projected.summary)
395
+ ? projected.summary
396
+ : (previous?.summary || projected.summary);
397
+ const call = {
398
+ ...previous,
399
+ ...projected,
400
+ summary,
401
+ input: projected.input ?? previous?.input ?? null,
402
+ // Some app-server versions stream output deltas but omit aggregatedOutput on the
403
+ // terminal item. Preserve the accumulated live result in that case.
404
+ result: projected.result ?? previous?.result ?? null,
405
+ };
406
+ toolCalls.set(call.id, call);
407
+ ctx.emit({ type: 'tool', call });
270
408
  }
271
409
  break;
272
410
  }
@@ -21,6 +21,53 @@ function statSafe(p) {
21
21
  return null;
22
22
  }
23
23
  }
24
+ // A transcript's title/preview/model/effort/turns all live in a BOUNDED slice of the file: the HEAD
25
+ // (first prompt, model, ai-title) and the TAIL (latest reply, last turn_context). We read only those
26
+ // slices — never the whole file — so discovery stays O(bounded) per session even when a rollout grows
27
+ // to hundreds of MB. Results are memoized by the file's (mtime,size): an unchanged transcript is read
28
+ // once and every later list call is free, so adding the tail read costs nothing on repeat. Any real
29
+ // change moves mtime+size and re-reads, so the cache can never go stale.
30
+ const HEAD_BYTES = 256 * 1024;
31
+ const TAIL_BYTES = 256 * 1024;
32
+ /** Read a bounded byte region as UTF-8. A partial leading/trailing line is expected; callers skip unparseable lines. */
33
+ function readRegion(filePath, start, length) {
34
+ const fd = fs.openSync(filePath, 'r');
35
+ try {
36
+ const buf = Buffer.alloc(Math.max(0, length));
37
+ const n = fs.readSync(fd, buf, 0, buf.length, Math.max(0, start));
38
+ return buf.toString('utf8', 0, n);
39
+ }
40
+ finally {
41
+ fs.closeSync(fd);
42
+ }
43
+ }
44
+ function memoized(cache, filePath, stat, compute) {
45
+ const hit = cache.get(filePath);
46
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size)
47
+ return hit.value;
48
+ const value = compute();
49
+ cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
50
+ return value;
51
+ }
52
+ /** Extract plain text from a Codex `response_item` message content (array of `{type,text}` blocks). */
53
+ function codexText(content) {
54
+ if (typeof content === 'string')
55
+ return content;
56
+ if (!Array.isArray(content))
57
+ return '';
58
+ return content
59
+ .filter((b) => b && typeof b.text === 'string' && (b.type === 'text' || (b.type ?? '').endsWith('_text')))
60
+ .map((b) => b.text)
61
+ .join('');
62
+ }
63
+ /** Reasoning effort from a Codex `turn_context` payload: top-level `effort`, else the nested collaboration setting. */
64
+ function codexEffortOf(payload) {
65
+ const top = payload?.effort;
66
+ if (typeof top === 'string' && top.trim())
67
+ return top.trim();
68
+ const re = payload?.collaboration_mode?.settings?.reasoning_effort;
69
+ return typeof re === 'string' && re.trim() ? re.trim() : null;
70
+ }
24
71
  // ---- Claude: ~/.claude/projects/<encoded-workdir>/<sessionId>.jsonl ----
25
72
  /** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
26
73
  export function encodeClaudeProjectDir(workdir) {
@@ -40,52 +87,80 @@ function claudeText(content) {
40
87
  }
41
88
  return parts.join(' ');
42
89
  }
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;
90
+ const claudeMetaCache = new Map();
91
+ function readClaudeMeta(filePath, stat) {
92
+ return memoized(claudeMetaCache, filePath, stat, () => {
93
+ const size = stat.size;
94
+ let title = null;
95
+ let headModel = null;
96
+ let lastUser = null;
97
+ let headAssistant = null;
98
+ let turns = 0;
99
+ let head = '';
64
100
  try {
65
- ev = JSON.parse(line);
101
+ head = readRegion(filePath, 0, Math.min(HEAD_BYTES, Math.max(65536, size)));
66
102
  }
67
103
  catch {
68
- continue;
104
+ return { title: null, model: null, preview: null, turns: 0 };
69
105
  }
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++;
106
+ for (const line of head.split('\n')) {
107
+ if (!line || line[0] !== '{')
108
+ continue;
109
+ let ev;
110
+ try {
111
+ ev = JSON.parse(line);
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ if (ev.type === 'user' && ev.isMeta !== true) {
117
+ const text = claudeText(ev.message?.content).trim();
118
+ if (text && !text.startsWith('<') && !text.startsWith('[Image:')) {
119
+ if (!title)
120
+ title = cleanTitle(text);
121
+ lastUser = text;
122
+ turns++;
123
+ }
124
+ }
125
+ else if (ev.type === 'assistant') {
126
+ if (!headModel && ev.message?.model && ev.message.model !== '<synthetic>')
127
+ headModel = ev.message.model;
128
+ const text = claudeText(ev.message?.content).trim();
129
+ if (text)
130
+ headAssistant = text;
77
131
  }
78
132
  }
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;
133
+ // The LATEST reply lives at the end of a long transcript, not in the head — read a bounded tail
134
+ // slice for an accurate preview (and the most recent model). Skipped when the file already fits
135
+ // in the head window (then the head scan above already saw the whole thing).
136
+ let tailAssistant = null;
137
+ let tailModel = null;
138
+ if (size > HEAD_BYTES) {
139
+ try {
140
+ for (const line of readRegion(filePath, size - TAIL_BYTES, TAIL_BYTES).split('\n')) {
141
+ if (!line || line[0] !== '{')
142
+ continue;
143
+ let ev;
144
+ try {
145
+ ev = JSON.parse(line);
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ if (ev.type !== 'assistant')
151
+ continue;
152
+ const text = claudeText(ev.message?.content).trim();
153
+ if (text)
154
+ tailAssistant = text;
155
+ if (ev.message?.model && ev.message.model !== '<synthetic>')
156
+ tailModel = ev.message.model;
157
+ }
158
+ }
159
+ catch { /* keep head-derived values */ }
85
160
  }
86
- }
87
- const preview = cleanTitle(lastAssistant || lastUser, 200);
88
- return { title, model, preview, turns };
161
+ const preview = cleanTitle(tailAssistant || headAssistant || lastUser, 200);
162
+ return { title, model: tailModel || headModel, preview, turns };
163
+ });
89
164
  }
90
165
  export function discoverClaudeNativeSessions(workdir, opts = {}) {
91
166
  const home = homeOf(opts);
@@ -111,7 +186,7 @@ export function discoverClaudeNativeSessions(workdir, opts = {}) {
111
186
  const selected = typeof opts.limit === 'number' ? files.slice(0, Math.max(0, opts.limit)) : files;
112
187
  const now = Date.now();
113
188
  return selected.map(({ sessionId, filePath, stat }) => {
114
- const { title, model, preview, turns } = readClaudeHead(filePath, stat.size);
189
+ const { title, model, preview, turns } = readClaudeMeta(filePath, stat);
115
190
  return {
116
191
  sessionId,
117
192
  title,
@@ -148,6 +223,18 @@ function loadCodexTitleIndex(home) {
148
223
  }
149
224
  return map;
150
225
  }
226
+ // A rollout's `session_meta` (id/cwd/timestamp) is written once at creation and never changes, so its
227
+ // parsed head is cached by path permanently — the per-list cwd filter then costs one 8 KB read per file
228
+ // only the FIRST time it is ever seen, not on every list call.
229
+ const codexHeadCache = new Map();
230
+ function readCodexHeadCached(filePath) {
231
+ const hit = codexHeadCache.get(filePath);
232
+ if (hit !== undefined)
233
+ return hit;
234
+ const v = readCodexHead(filePath);
235
+ codexHeadCache.set(filePath, v);
236
+ return v;
237
+ }
151
238
  function readCodexHead(filePath) {
152
239
  try {
153
240
  const fd = fs.openSync(filePath, 'r');
@@ -173,6 +260,81 @@ function readCodexHead(filePath) {
173
260
  return null;
174
261
  }
175
262
  }
263
+ const codexMetaCache = new Map();
264
+ /**
265
+ * Bounded per-row metadata for a Codex rollout: the first user prompt (title fallback when the thread
266
+ * has no index name) from a HEAD slice, and the latest reply + last model/effort from a TAIL slice.
267
+ * Memoized by (mtime,size); never reads the whole file.
268
+ */
269
+ function readCodexBoundedMeta(filePath, stat) {
270
+ return memoized(codexMetaCache, filePath, stat, () => {
271
+ let firstPrompt = null;
272
+ let firstAny = null;
273
+ let preview = null;
274
+ let model = null;
275
+ let effort = null;
276
+ try {
277
+ // HEAD: the first real user turn (skip a leading <handover> injected by an agent switch).
278
+ for (const line of readRegion(filePath, 0, Math.min(HEAD_BYTES, stat.size)).split('\n')) {
279
+ const t = line.trim();
280
+ if (!t || t[0] !== '{' || !t.includes('user_message'))
281
+ continue;
282
+ let ev;
283
+ try {
284
+ ev = JSON.parse(t);
285
+ }
286
+ catch {
287
+ continue;
288
+ }
289
+ if (ev.type !== 'event_msg' || ev.payload?.type !== 'user_message')
290
+ continue;
291
+ const text = String(ev.payload.message ?? '').trim();
292
+ if (!text)
293
+ continue;
294
+ if (!firstAny)
295
+ firstAny = text;
296
+ if (!String(text).toLowerCase().startsWith('<handover')) {
297
+ firstPrompt = text;
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ catch { /* best-effort */ }
303
+ try {
304
+ // TAIL: the latest assistant reply + last turn_context (model/effort).
305
+ const start = Math.max(0, stat.size - TAIL_BYTES);
306
+ for (const line of readRegion(filePath, start, Math.min(TAIL_BYTES, stat.size)).split('\n')) {
307
+ const t = line.trim();
308
+ if (!t || t[0] !== '{')
309
+ continue;
310
+ let ev;
311
+ try {
312
+ ev = JSON.parse(t);
313
+ }
314
+ catch {
315
+ continue;
316
+ }
317
+ const p = ev.payload;
318
+ if (!p)
319
+ continue;
320
+ if (ev.type === 'turn_context') {
321
+ if (typeof p.model === 'string' && p.model.trim())
322
+ model = p.model.trim();
323
+ const e = codexEffortOf(p);
324
+ if (e)
325
+ effort = e;
326
+ }
327
+ else if (ev.type === 'response_item' && p.type === 'message' && p.role === 'assistant') {
328
+ const text = codexText(p.content).trim();
329
+ if (text)
330
+ preview = text;
331
+ }
332
+ }
333
+ }
334
+ catch { /* best-effort */ }
335
+ return { firstPrompt: cleanTitle(firstPrompt ?? firstAny), preview: cleanTitle(preview, 200), model, effort };
336
+ });
337
+ }
176
338
  export function discoverCodexNativeSessions(workdir, opts = {}) {
177
339
  const home = homeOf(opts);
178
340
  const sessionsDir = path.join(home, '.codex', 'sessions');
@@ -206,7 +368,7 @@ export function discoverCodexNativeSessions(workdir, opts = {}) {
206
368
  const out = [];
207
369
  const seen = new Set();
208
370
  for (const { filePath, stat } of files) {
209
- const meta = readCodexHead(filePath);
371
+ const meta = readCodexHeadCached(filePath);
210
372
  if (!meta || meta.isSubagent || path.resolve(meta.cwd) !== resolvedWorkdir)
211
373
  continue;
212
374
  if (seen.has(meta.sessionId))
@@ -214,12 +376,17 @@ export function discoverCodexNativeSessions(workdir, opts = {}) {
214
376
  seen.add(meta.sessionId);
215
377
  const idx = titleIndex.get(meta.sessionId);
216
378
  const updatedAt = idx?.updatedAt || stat.mtime.toISOString();
379
+ // Only files that passed the cwd/subagent filter above pay for the bounded head+tail read (first
380
+ // prompt / preview / model / effort) — scoped to THIS workdir's own rollouts, and memoized per file.
381
+ const bounded = readCodexBoundedMeta(filePath, stat);
217
382
  out.push({
218
383
  sessionId: meta.sessionId,
219
- title: cleanTitle(idx?.threadName || null),
220
- preview: null,
384
+ // The agent's own thread name wins; otherwise fall back to the first prompt so the row is named + searchable.
385
+ title: cleanTitle(idx?.threadName || null) ?? bounded.firstPrompt,
386
+ preview: bounded.preview,
221
387
  cwd: meta.cwd,
222
- model: null,
388
+ model: bounded.model,
389
+ effort: bounded.effort,
223
390
  createdAt: meta.timestamp || stat.birthtime.toISOString(),
224
391
  updatedAt,
225
392
  running: Date.now() - Date.parse(updatedAt) < threshold,
@@ -16,6 +16,7 @@ export interface ManagedSessionInfo {
16
16
  source: SessionSource;
17
17
  createdAt: string | null;
18
18
  updatedAt: string | null;
19
+ messageCount?: number | null;
19
20
  }
20
21
  export interface ListSessionsOptions {
21
22
  /**
@@ -28,6 +29,8 @@ export interface ListSessionsOptions {
28
29
  agent?: string;
29
30
  includeNative?: boolean;
30
31
  limit?: number;
32
+ /** Skip this many of the most-recent rows before taking `limit` — for paginated "load more". */
33
+ offset?: number;
31
34
  }
32
35
  export interface SearchSessionsOptions extends ListSessionsOptions {
33
36
  query: string;
@@ -24,6 +24,7 @@ function managedToInfo(agent, rec) {
24
24
  source: 'managed',
25
25
  createdAt: rec.createdAt ?? null,
26
26
  updatedAt: rec.updatedAt ?? null,
27
+ messageCount: null, // managed records don't track a turn count; native rows carry the head-approx one
27
28
  };
28
29
  }
29
30
  function nativeToInfo(agent, n) {
@@ -35,12 +36,13 @@ function nativeToInfo(agent, n) {
35
36
  preview: n.preview,
36
37
  workdir: n.cwd,
37
38
  model: n.model,
38
- effort: null,
39
+ effort: n.effort ?? null,
39
40
  runState: n.running ? 'running' : 'completed',
40
41
  running: n.running,
41
42
  source: 'native',
42
43
  createdAt: n.createdAt,
43
44
  updatedAt: n.updatedAt,
45
+ messageCount: n.messageCount ?? null,
44
46
  };
45
47
  }
46
48
  export class SessionsManager {
@@ -55,6 +57,15 @@ export class SessionsManager {
55
57
  const drivers = this.deps.drivers();
56
58
  const agentIds = opts.agent ? [opts.agent] : [...drivers.keys()];
57
59
  const byKey = new Map();
60
+ // Native discovery must surface enough of the newest rows to satisfy offset+limit paging, since
61
+ // the final page is sliced AFTER merging managed + native. `undefined` limit = discover all.
62
+ const offset = Math.max(0, opts.offset ?? 0);
63
+ const nativeLimit = typeof opts.limit === 'number' ? offset + opts.limit : undefined;
64
+ // Native session keys a managed record already represents because the agent minted a DIVERGENT
65
+ // transcript id (rec.sessionId ≠ rec.nativeSessionId — the new-chat case). Those native rows are
66
+ // dropped below so they don't double up with their managed row. Deduping HERE (before paging) keeps
67
+ // page sizes and "has more" correct — doing it in a host wrapper after slicing shrinks pages.
68
+ const coveredNativeKeys = new Set();
58
69
  // Managed sessions (the kernel's own store).
59
70
  for (const agent of agentIds) {
60
71
  let records = [];
@@ -65,6 +76,9 @@ export class SessionsManager {
65
76
  this.deps.log?.(`[sessions] store.list(${agent}) failed: ${e?.message || e}`);
66
77
  }
67
78
  for (const rec of records) {
79
+ if (rec.nativeSessionId && rec.nativeSessionId !== rec.sessionId) {
80
+ coveredNativeKeys.add(makeSessionKey(agent, rec.nativeSessionId));
81
+ }
68
82
  if (scope === 'workspace') {
69
83
  if (!rec.workdir || path.resolve(rec.workdir) !== workdir)
70
84
  continue;
@@ -80,13 +94,15 @@ export class SessionsManager {
80
94
  continue;
81
95
  let natives = [];
82
96
  try {
83
- natives = await driver.listNativeSessions({ workdir, limit: opts.limit });
97
+ natives = await driver.listNativeSessions({ workdir, limit: nativeLimit });
84
98
  }
85
99
  catch (e) {
86
100
  this.deps.log?.(`[sessions] ${agent}.listNativeSessions failed: ${e?.message || e}`);
87
101
  }
88
102
  for (const n of natives) {
89
103
  const key = makeSessionKey(agent, n.sessionId);
104
+ if (coveredNativeKeys.has(key))
105
+ continue; // a managed record already represents this transcript
90
106
  const existing = byKey.get(key);
91
107
  if (existing) {
92
108
  // Same identity discovered both ways: managed record wins, but adopt the newer
@@ -104,15 +120,16 @@ export class SessionsManager {
104
120
  }
105
121
  }
106
122
  const out = [...byKey.values()].sort((a, b) => ts(b.updatedAt) - ts(a.updatedAt));
107
- return typeof opts.limit === 'number' ? out.slice(0, Math.max(0, opts.limit)) : out;
123
+ return typeof opts.limit === 'number' ? out.slice(offset, offset + opts.limit) : offset ? out.slice(offset) : out;
108
124
  }
109
125
  async search(opts) {
110
126
  const q = (opts.query || '').trim().toLowerCase();
111
- const all = await this.list({ ...opts, limit: undefined });
127
+ const all = await this.list({ ...opts, limit: undefined, offset: 0 });
112
128
  const matched = q
113
129
  ? all.filter(s => [s.title, s.preview, s.sessionId, s.model, s.agent].some(v => v != null && v.toLowerCase().includes(q)))
114
130
  : all;
115
- return typeof opts.limit === 'number' ? matched.slice(0, Math.max(0, opts.limit)) : matched;
131
+ const offset = Math.max(0, opts.offset ?? 0);
132
+ return typeof opts.limit === 'number' ? matched.slice(offset, offset + opts.limit) : offset ? matched.slice(offset) : matched;
116
133
  }
117
134
  async get(sessionKey, opts = {}) {
118
135
  const { agent, sessionId } = splitSessionKey(sessionKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",