pikiloom 0.4.62 → 0.4.64

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
@@ -368,7 +368,7 @@ Questions, feedback, or want to compare notes on agent orchestration? Add me on
368
368
  ## Star History
369
369
 
370
370
  <a href="https://www.star-history.com/#xiaotonng/pikiloom&Date">
371
- <img src="https://api.star-history.com/svg?repos=xiaotonng/pikiloom&type=Date" alt="Star History" width="640">
371
+ <img src="https://raw.githubusercontent.com/xiaotonng/pikiloom/main/docs/star-history.svg" alt="Star History" width="640">
372
372
  </a>
373
373
 
374
374
  ---
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
- import { MANAGED_BROWSER_PROFILE_SUBPATH, PIKILOOM_BROWSER_CDP_URL_ENV, PLAYWRIGHT_MCP_PACKAGE_NAME, PLAYWRIGHT_MCP_PACKAGE_SPEC, PLAYWRIGHT_MCP_BROWSER_ARGS, } from './core/constants.js';
6
+ import { MANAGED_BROWSER_PROFILE_SUBPATH, MANAGED_BROWSER_STEALTH_ARGS, PIKILOOM_BROWSER_CDP_URL_ENV, PLAYWRIGHT_MCP_PACKAGE_NAME, PLAYWRIGHT_MCP_PACKAGE_SPEC, PLAYWRIGHT_MCP_BROWSER_ARGS, } from './core/constants.js';
7
7
  const MANAGED_BROWSER_SETUP_STATE_FILENAME = 'managed-browser-setup.json';
8
8
  const MANAGED_BROWSER_SHUTDOWN_TIMEOUT_MS = 5_000;
9
9
  const MANAGED_BROWSER_SHUTDOWN_POLL_MS = 100;
@@ -148,7 +148,7 @@ export function ensurePlaywrightMcpConfigFile(outputDir = path.dirname(getManage
148
148
  const desired = JSON.stringify({
149
149
  browser: {
150
150
  launchOptions: {
151
- ignoreDefaultArgs: ['--disable-blink-features=AutomationControlled'],
151
+ args: [...MANAGED_BROWSER_STEALTH_ARGS],
152
152
  },
153
153
  },
154
154
  }, null, 2);
@@ -211,6 +211,7 @@ export function getManagedBrowserLaunchArgs(profileDir = getManagedBrowserProfil
211
211
  return [
212
212
  `--user-data-dir=${profileDir}`,
213
213
  '--remote-debugging-port=0',
214
+ ...MANAGED_BROWSER_STEALTH_ARGS,
214
215
  '--no-first-run',
215
216
  '--no-default-browser-check',
216
217
  '--new-window',
@@ -26,6 +26,10 @@ export const PLAYWRIGHT_MCP_PACKAGE_NAME = '@playwright/mcp';
26
26
  export const PLAYWRIGHT_MCP_PACKAGE_VERSION = '0.0.75';
27
27
  export const PLAYWRIGHT_MCP_PACKAGE_SPEC = `${PLAYWRIGHT_MCP_PACKAGE_NAME}@${PLAYWRIGHT_MCP_PACKAGE_VERSION}`;
28
28
  export const PLAYWRIGHT_MCP_BROWSER_ARGS = ['--browser', 'chrome', '--viewport-size', '1920x1080'];
29
+ // Chrome 150+ sets navigator.webdriver=true whenever remote debugging is on, which trips
30
+ // Google's "This browser or app may not be secure" sign-in block; --test-type suppresses
31
+ // the "unsupported command-line flag" infobar that the first flag would otherwise trigger.
32
+ export const MANAGED_BROWSER_STEALTH_ARGS = ['--disable-blink-features=AutomationControlled', '--test-type'];
29
33
  export const PIKILOOM_BROWSER_CDP_URL_ENV = 'PIKILOOM_BROWSER_CDP_URL';
30
34
  export const DASHBOARD_PAGINATION = {
31
35
  defaultPageSize: 6,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.62",
3
+ "version": "0.4.64",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;
@@ -24,10 +24,11 @@ export declare function claudeBgAgentHoldCapMs(): number;
24
24
  export declare function claudeTurnHasAgentBackground(s: any): boolean;
25
25
  export declare function claudeBgHoldRecheckMs(): number;
26
26
  export declare function claudeBgSettleQuietMs(): number;
27
- export declare function claudeModelStallMs(): number;
27
+ export declare function claudeModelStallMs(effort?: string | null): number;
28
28
  export declare const CLAUDE_TRUNCATED_RECOVERY_PROMPT: string;
29
29
  export declare function claudeTruncatedRecoveryEnabled(): boolean;
30
30
  export declare function isClaudeSyntheticResumeNoise(text: string): boolean;
31
+ export declare function isClaudeSlashCommand(prompt: string): boolean;
31
32
  export declare function claudeProducedRealOutput(s: any): boolean;
32
33
  export declare function claudeResumeNoopRetryLimit(): number;
33
34
  export declare function claudeUserEventHasToolResult(ev: any): boolean;
@@ -193,7 +193,7 @@ export class ClaudeDriver {
193
193
  return;
194
194
  }
195
195
  settleResult({ stopReason: 'stalled', kill: false, ok: false });
196
- }, claudeModelStallMs());
196
+ }, claudeModelStallMs(input.effort));
197
197
  unref(modelStallTimer);
198
198
  };
199
199
  try {
@@ -270,7 +270,13 @@ export class ClaudeDriver {
270
270
  // through its repair to a real answer within this one turn. Bounded (the repair can
271
271
  // no-op more than once); the post-tool stall watchdog is the backstop if the CLI never
272
272
  // engages. Scoped to resumes (input.sessionId) — a fresh session has no dangling turn.
273
+ // Excludes local slash commands (isClaudeSlashCommand): /compact, /clear & friends are
274
+ // LEGITIMATELY output-empty (their effect is a local action, not an assistant reply), so
275
+ // the emptiness that flags a dropped send is normal for them. Re-issuing one is never a
276
+ // repair — for /compact it just fires a second compaction that reports "Not enough
277
+ // messages to compact." A real dropped send is a plain prompt and still self-heals.
273
278
  if (!hasError && !!input.sessionId && !claudeProducedRealOutput(state)
279
+ && !isClaudeSlashCommand(input.prompt)
274
280
  && noopResumeRetries < claudeResumeNoopRetryLimit()) {
275
281
  noopResumeRetries++;
276
282
  let injected = false;
@@ -534,6 +540,23 @@ export function handleClaudeEvent(ev, s, emit) {
534
540
  // as the reply nor counts as real output (the no-op-resume recovery keys off that emptiness).
535
541
  if (ev.message?.model === '<synthetic>' && isClaudeSyntheticResumeNoise(claudeContentText(ev.message?.content)))
536
542
  return;
543
+ // API-error message: Claude surfaces a failed model call (401 auth, overloaded, quota, …) not as a
544
+ // result code but as a synthetic assistant message — model '<synthetic>', a lone text block carrying
545
+ // the human-readable error ("Failed to authenticate. API Error: 401 …"), and a TOP-LEVEL `error` tag
546
+ // on the event (e.g. "authentication_failed"; the persisted transcript also stamps `isApiErrorMessage`).
547
+ // It is NOT model output. Routing its text through the normal path below would make the error render
548
+ // as the assistant's reply body (原文). Send it to `s.error` — the run-end notice, same slot as the
549
+ // `result{is_error}` branch below — and never to `s.text`. The trailing `result` also flags the error
550
+ // (and would set `s.error` too), but claiming it here keeps a narration-less turn from double-rendering
551
+ // (body + notice), and preserves any real narration already streamed before the call failed.
552
+ const apiErrorTag = typeof ev.error === 'string' ? ev.error.trim() : (ev.isApiErrorMessage ? 'api_error' : '');
553
+ if (apiErrorTag) {
554
+ if (!s.error) {
555
+ const msg = claudeContentText(ev.message?.content).trim();
556
+ s.error = msg || `Claude reported an API error (${apiErrorTag})`;
557
+ }
558
+ return;
559
+ }
537
560
  const contents = ev.message?.content || [];
538
561
  for (const b of contents) {
539
562
  if (b?.type !== 'tool_use')
@@ -778,15 +801,22 @@ export function claudeBgSettleQuietMs() {
778
801
  return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_SETTLE_QUIET_DEFAULT_MS;
779
802
  }
780
803
  // How long the model may stay COMPLETELY silent after a tool_result (control handed back to it,
781
- // no background pending) before the driver gives up and settles the turn as 'stalled'. Deliberately
782
- // generous: legitimate silent extended-thinking (subscription accounts stream no thinking text) and
783
- // slow providers must not trip it, and a still-running tool never trips it (it has no tool_result
784
- // yet). This is the backstop for a turn that would otherwise hang forever with the answer never
785
- // delivered. Override with PIKILOOM_CLAUDE_MODEL_STALL_MS.
786
- const CLAUDE_MODEL_STALL_DEFAULT_MS = 120_000;
787
- export function claudeModelStallMs() {
804
+ // no background pending) before the driver gives up and settles the turn as 'stalled'. Must be
805
+ // generous: subscription accounts stream NO events during extended thinking, and at the reasoning
806
+ // rungs a legitimate silent think regularly exceeds two minutes the original 120s default
807
+ // misfired on exactly that (settling a LIVE turn as 'stalled' and then killing its still-running
808
+ // tool via the leak-guard; mirasim#111). A too-long window only means a truly hung turn shows its
809
+ // dead spinner longer, so the costs are asymmetric — err long. Effort-laddered: the deep-reasoning
810
+ // rungs (high and up) think the longest. A still-running tool never trips this (it has no
811
+ // tool_result yet). Override with PIKILOOM_CLAUDE_MODEL_STALL_MS (wins over the ladder).
812
+ const CLAUDE_MODEL_STALL_DEFAULT_MS = 300_000;
813
+ const CLAUDE_MODEL_STALL_DEEP_MS = 600_000;
814
+ const CLAUDE_DEEP_REASONING_EFFORTS = new Set(['high', 'xhigh', 'max', 'ultra']);
815
+ export function claudeModelStallMs(effort) {
788
816
  const raw = Number(process.env.PIKILOOM_CLAUDE_MODEL_STALL_MS);
789
- return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_MODEL_STALL_DEFAULT_MS;
817
+ if (Number.isFinite(raw) && raw > 0)
818
+ return raw;
819
+ return effort && CLAUDE_DEEP_REASONING_EFFORTS.has(effort) ? CLAUDE_MODEL_STALL_DEEP_MS : CLAUDE_MODEL_STALL_DEFAULT_MS;
790
820
  }
791
821
  // In-process self-heal for a truncated turn: when a clean result lands while the tool loop is
792
822
  // still dangling (the model's closing round came back empty), the stdin is still open — inject
@@ -809,6 +839,15 @@ export function isClaudeSyntheticResumeNoise(text) {
809
839
  const t = (text || '').trim().toLowerCase();
810
840
  return t === 'no response requested.' || t === 'no response requested';
811
841
  }
842
+ // A prompt whose first token is a Claude slash command (/compact, /clear, /cost, /model …, a custom
843
+ // /namespace:command). Such a prompt is a LOCAL action, not a request for a model reply, so an
844
+ // output-empty result is expected — not the dropped-send signature the no-op-resume repair targets.
845
+ // The first token must be a bare command name (letters/digits/_/- with an optional :namespace) ending
846
+ // at whitespace or end-of-string, so a filesystem-style path (/Users/foo) is NOT matched. Pure +
847
+ // exported for hermetic testing.
848
+ export function isClaudeSlashCommand(prompt) {
849
+ return /^\/[a-z0-9][\w-]*(?::[\w-]+)?(?:\s|$)/i.test((prompt || '').trimStart());
850
+ }
812
851
  // True once the turn produced ANY real model output — streamed or whole-message text/reasoning, a
813
852
  // tool use, or a spawned sub-agent. False for a pure no-op (a synthetic resume-repair result that
814
853
  // ran none of the prompt), which is exactly what the no-op-resume recovery keys off. Pure +
@@ -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,
@@ -120,6 +120,12 @@ export class Hub {
120
120
  extraArgs: spawn.extraArgs,
121
121
  configOverrides: spawn.configOverrides,
122
122
  extraMcpServers: tools.servers,
123
+ // The managed path's control verb `LoomIO.steer()` can only reach a driver that was
124
+ // launched steer-enabled, so honor the driver's declared capability here — otherwise
125
+ // steer is a silent no-op for steer-capable agents (claude gates registerSteer +
126
+ // --replay-user-messages on this flag). Drivers that don't support steer stay
127
+ // un-steerable; those that steer over their own channel regardless (codex RPC) ignore it.
128
+ steerable: !!driver.capabilities?.steer,
123
129
  };
124
130
  runner.run(driver, turnInput, input.prompt, model, effort)
125
131
  .then(async (result) => {
@@ -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);