pisesh 0.1.13 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-08-22
6
+
7
+ ### Changed
8
+ - Make `/sesh` return the selected session to its extension and switch through pi's official `ctx.switchSession()` API instead of launching a nested pi process. Standalone `pisesh` still launches pi.
9
+ - Preserve `Enter` current-default and `o` session-recorded model and thinking behavior across native session switches.
10
+
11
+ ### Fixed
12
+ - Forward custom cwd overrides when supported by pi, warn when pi ignores them, and skip switching when the selected session is already active.
13
+ - Keep interrupted-tool-call repair after confirming pi does not synthesize missing results during session loading.
14
+
15
+ ## [0.2.0] - 2026-08-01
16
+
17
+ ### Added
18
+ - Add background LLM title generation: `g` queues a session using the saved model and effort, while `G` opens generation settings. Up to three titles generate concurrently. Based on #2 by @ahoereth.
19
+
20
+ ### Fixed
21
+ - Keep extension-backed model providers available while disabling context files, skills, prompt templates, and tools for title generation.
22
+ - Preserve manual titles during queued generation, reject malformed or control-sequence output, and report generation failures visibly.
23
+ - Forward custom agent directories to spawned pi processes and terminate cancelled generation processes safely.
24
+
5
25
  ## [0.1.13] - 2026-08-01
6
26
 
7
27
  ### Added
package/README.md CHANGED
@@ -45,14 +45,14 @@ Pi accumulates sessions across many working directories: your home, several proj
45
45
  - You re-open the wrong session and pollute it with unrelated context
46
46
  - You waste time searching by timestamp guessing
47
47
 
48
- pisesh is a **single-file Node script** (no dependencies, ~1,100 LoC) that gives you everything `pi --resume` doesn't.
48
+ pisesh is a **single-file Node script** (no dependencies, ~1,600 LoC) that gives you everything `pi --resume` doesn't.
49
49
 
50
50
  ### Value at a glance
51
51
 
52
52
  | Need | What you get |
53
53
  | ------------------------------------------ | ---------------------------------------------------------------------------- |
54
54
  | Mark important sessions | ⭐ Star/unstar with one keystroke; favorites persist to one global JSON |
55
- | Give a thread a real name | `e` sets a custom title (marked `✎`); overrides the first-prompt label |
55
+ | Give a thread a real name | `e` sets one manually; `g` generates one with a model you choose |
56
56
  | See only the current project's sessions | `Here` tab filters to sessions whose cwd matches where you launched pisesh |
57
57
  | Fix where a session resumes | `p` opens an arrow-key directory browser; sets the cwd pi `cd`s into |
58
58
  | Find a session by what you said | `/` searches id + project + first user prompt + custom title |
@@ -73,6 +73,8 @@ pi install npm:pisesh
73
73
 
74
74
  This registers pisesh as a pi extension. Inside any pi session, type `/sesh`. The extension runs its bundled CLI, so a global npm installation is not required.
75
75
 
76
+ `/sesh` does not start a second pi process. The picker returns the selected session and options to the extension, which calls pi's official `ctx.switchSession()` API. Standalone `pisesh` keeps its shell behavior and starts `pi --session`. Custom cwd overrides require a pi version that supports `cwdOverride` on extension session switches; pisesh warns if pi ignores one.
77
+
76
78
  ### Install the standalone CLI
77
79
 
78
80
  ```bash
@@ -104,15 +106,19 @@ For local pi testing, run `pi install .` from the cloned repository so the exten
104
106
  | `Enter` | resume using the current default model and thinking settings |
105
107
  | `o` | resume using the model and thinking recorded in the session |
106
108
  | `e` | edit name: set a custom display title, shown with `✎` in the list |
109
+ | `g` | queue title generation with the saved model and effort; clear a manual title with `e` first |
110
+ | `G` | open title-generation settings to choose the saved model + effort |
107
111
  | `p` | edit cwd with an arrow-key directory browser; sets the resume / `Here` dir |
108
112
  | `d` | session details (full prompt, file, byte size, timestamps) |
109
113
  | `/` | search by id / project / first user prompt / custom title |
110
- | `Esc` | clear search first, then quit |
111
- | `q` / `Ctrl-C` | quit (terminal restored) |
114
+ | `Esc` / `q` | cancel generation or clear search first; press again to quit |
115
+ | `Ctrl-C` | cancel generation and quit immediately |
112
116
  | `r` | rescan session files (after pi starts a new session) |
113
117
  | `c` (in details view) | copy session id to clipboard (clip.exe / pbcopy / xclip) |
114
118
  | `Home` `End` `PgUp` `PgDn` | jump to top / bottom / ±10 |
115
119
 
120
+ Title generation sends up to 16 KB of session text to the selected model provider and may incur provider charges. It excludes tool results and disables context files, skills, prompt templates, and tools.
121
+
116
122
  ## CLI (non-TUI) usage
117
123
 
118
124
  For scripts and automation:
@@ -138,18 +144,19 @@ pisesh --help
138
144
  | Alt screen buffer | `\x1b[?1049h` / `\x1b[?1049l`, the same primitive `vim`, `less`, `htop`, and droid CLI use |
139
145
  | Input | Node's `readline.emitKeypressEvents` in raw mode |
140
146
  | Width calculation | UAX #11 East Asian Width ranges, compressed to ~10 inline range checks |
141
- | Pi extension | TypeScript factory using `@earendil-works/pi-coding-agent` extension API (`ui.custom`, `tui.stop`) |
142
- | Storage | Two JSON files: `~/.pi/agent/favorites.json` (starred ids) + `~/.pi/agent/pisesh-meta.json` (per-session title / cwd overrides) |
147
+ | Pi extension | TypeScript factory using `ui.custom`, `tui.stop`, and `ctx.switchSession()` |
148
+ | Storage | Two JSON files under `$PI_AGENT_DIR`: `favorites.json` and `pisesh-meta.json` |
143
149
  | Session discovery | Direct filesystem scan of `~/.pi/agent/sessions/<projectSlug>/*.jsonl`; first 96 KB parsed |
144
- | Process model | Slash command pauses pi's TUI, spawns pisesh with inherited stdio, restarts pi on exit |
150
+ | Process model | `/sesh` runs pisesh as a selector and switches the current runtime; standalone starts `pi` |
145
151
  | Resume settings | `Enter` uses current defaults; `o` preserves the model and thinking recorded in the session |
146
152
  | Custom paths | Honors `PI_AGENT_DIR` and `PI_SESSION_DIR`, including a flat custom session directory |
153
+ | Title generation | Ephemeral `pi --print --no-session` call using the model and effort selected in pisesh |
147
154
 
148
155
  ### What it explicitly does **not** depend on
149
156
 
150
157
  - No `npm install` for the bundled CLI runtime; it's genuinely zero-dependency
151
158
  - No native binaries / GPU / ffmpeg / database
152
- - No network calls, no telemetry, no analytics
159
+ - No telemetry or analytics; title generation contacts only the provider for the model you select
153
160
  - No daemon / background process
154
161
 
155
162
  ## Storage
@@ -157,7 +164,7 @@ pisesh --help
157
164
  | What | Where |
158
165
  | ---------- | ----------------------------------------------------------- |
159
166
  | Favorites | `$PI_AGENT_DIR/favorites.json` (defaults to `~/.pi/agent/favorites.json`) |
160
- | Overrides | `$PI_AGENT_DIR/pisesh-meta.json` (per-session custom title / cwd, keyed by session id) |
167
+ | Overrides | `$PI_AGENT_DIR/pisesh-meta.json` (per-session title / cwd plus the saved title model + effort preset) |
161
168
  | Sessions | `$PI_SESSION_DIR`, or `$PI_AGENT_DIR/sessions` by default (repaired only when an orphaned tool call would break resume) |
162
169
 
163
170
  Favorites file shape:
@@ -193,7 +200,7 @@ Korean / Chinese / Japanese / fullwidth characters render **2 cells wide** in te
193
200
  - Windows: **Windows Terminal**, **WezTerm**, **Alacritty** ✅
194
201
  - macOS: **iTerm2**, **Terminal.app**, **WezTerm**, **Alacritty**, **Kitty** ✅
195
202
  - Linux: **GNOME Terminal**, **Konsole**, **xterm**, **Alacritty**, **Kitty** ✅
196
- - [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) on `$PATH` for the `Enter`-to-resume action
203
+ - [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) on `$PATH` when using standalone `pisesh`
197
204
 
198
205
  ## Contributing
199
206
 
package/bin/pisesh CHANGED
@@ -15,11 +15,21 @@ const readline = require('readline');
15
15
 
16
16
  // ── Paths ─────────────────────────────────────────────
17
17
  const HOME = os.homedir();
18
- const AGENT_DIR = path.resolve(process.env.PI_AGENT_DIR || path.join(HOME, '.pi/agent'));
19
- const SESSIONS_ROOT = path.resolve(process.env.PI_SESSION_DIR || path.join(AGENT_DIR, 'sessions'));
18
+ const AGENT_DIR = path.resolve(process.env.PI_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || path.join(HOME, '.pi/agent'));
19
+ const SESSIONS_ROOT = path.resolve(process.env.PI_SESSION_DIR || process.env.PI_CODING_AGENT_SESSION_DIR || path.join(AGENT_DIR, 'sessions'));
20
20
  const FAV_FILE = path.join(AGENT_DIR, 'favorites.json');
21
21
  const META_FILE = path.join(AGENT_DIR, 'pisesh-meta.json');
22
22
  const SETTINGS_FILE = path.join(AGENT_DIR, 'settings.json');
23
+ function piAgentEnv(baseEnv = process.env, agentDir = AGENT_DIR) {
24
+ return { ...baseEnv, PI_CODING_AGENT_DIR: agentDir };
25
+ }
26
+ const PI_ENV = piAgentEnv();
27
+ // The /sesh extension gives the picker a private result pipe. Standalone
28
+ // pisesh has no such fd and keeps launching pi itself.
29
+ const SELECT_FD = (() => {
30
+ const fd = Number(process.env.PISESH_SELECT_FD);
31
+ return Number.isInteger(fd) && fd >= 3 ? fd : null;
32
+ })();
23
33
  const VERSION = (() => {
24
34
  for (const file of [
25
35
  path.resolve(__dirname, '../package.json'),
@@ -91,12 +101,15 @@ let favorites = loadFavorites();
91
101
  function loadMeta() {
92
102
  try {
93
103
  const data = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
94
- return (data && typeof data.overrides === 'object' && data.overrides) || {};
95
- } catch { return {}; }
104
+ return {
105
+ overrides: (data && typeof data.overrides === 'object' && data.overrides) || {},
106
+ settings: (data && typeof data.settings === 'object' && data.settings) || {},
107
+ };
108
+ } catch { return { overrides: {}, settings: {} }; }
96
109
  }
97
110
 
98
- function saveMeta(overrides) {
99
- const data = { overrides, updated: new Date().toISOString() };
111
+ function saveMeta() {
112
+ const data = { settings: metaSettings, overrides: meta, updated: new Date().toISOString() };
100
113
  try {
101
114
  fs.mkdirSync(path.dirname(META_FILE), { recursive: true });
102
115
  fs.writeFileSync(META_FILE, JSON.stringify(data, null, 2));
@@ -105,7 +118,9 @@ function saveMeta(overrides) {
105
118
  }
106
119
  }
107
120
 
108
- let meta = loadMeta();
121
+ const loadedMeta = loadMeta();
122
+ let meta = loadedMeta.overrides;
123
+ let metaSettings = loadedMeta.settings;
109
124
 
110
125
  // Override record for one session id (always returns an object, never null).
111
126
  // Used by the per-session getters, which can't read the global `meta`
@@ -123,7 +138,32 @@ function setOverride(id, field, value) {
123
138
  else delete entry[field];
124
139
  if (Object.keys(entry).length) meta[id] = entry;
125
140
  else delete meta[id];
126
- saveMeta(meta);
141
+ saveMeta();
142
+ }
143
+
144
+ function saveTitle(id, value, source, model, thinkingLevel) {
145
+ if (!id) return;
146
+ const v = (value || '').trim();
147
+ const entry = meta[id] || {};
148
+ if (v) {
149
+ entry.title = v;
150
+ entry.titleSource = source;
151
+ if (source === 'llm' && model) {
152
+ entry.titleModel = model;
153
+ entry.titleThinkingLevel = thinkingLevel || 'off';
154
+ } else {
155
+ delete entry.titleModel;
156
+ delete entry.titleThinkingLevel;
157
+ }
158
+ } else {
159
+ delete entry.title;
160
+ delete entry.titleSource;
161
+ delete entry.titleModel;
162
+ delete entry.titleThinkingLevel;
163
+ }
164
+ if (Object.keys(entry).length) meta[id] = entry;
165
+ else delete meta[id];
166
+ saveMeta();
127
167
  }
128
168
 
129
169
  // ── Project slug decode ───────────────────────────────
@@ -235,6 +275,9 @@ function scanSessions() {
235
275
  get isCurrent() { return CURRENT_SESSION_ID !== '' && this.id === CURRENT_SESSION_ID; },
236
276
  // User overrides (sidecar meta). cwd: from is the original recorded cwd.
237
277
  get titleOverride() { return (overridesFor(this.id).title || '').trim(); },
278
+ get titleSource() { return overridesFor(this.id).titleSource || (this.titleOverride ? 'manual' : ''); },
279
+ get titleModel() { return overridesFor(this.id).titleModel || ''; },
280
+ get titleThinkingLevel() { return overridesFor(this.id).titleThinkingLevel || ''; },
238
281
  get cwdOverride() { return (overridesFor(this.id).cwd || '').trim(); },
239
282
  // Name shown in the list: custom title > first prompt > id fallback.
240
283
  get title() {
@@ -266,7 +309,7 @@ let tabIdx = 0;
266
309
  let cursor = 0;
267
310
  let filter = '';
268
311
  let notice = '';
269
- let mode = 'list'; // list | filter | details | edit | browse
312
+ let mode = 'list'; // list | filter | details | edit | browse | model
270
313
  // edit mode state (title text editor)
271
314
  let editField = ''; // 'title' | 'cwd'
272
315
  let editBuffer = '';
@@ -276,6 +319,185 @@ let editTarget = null; // the session being edited
276
319
  let browseDir = ''; // directory currently being browsed (absolute)
277
320
  let browseEntries = []; // [{ label, kind:'use'|'up'|'dir', path }]
278
321
  let browseSel = 0; // selected row in browseEntries
322
+ // LLM title generation state
323
+ let models = [];
324
+ let modelSel = 0;
325
+ let modelFilter = '';
326
+ let modelStatus = '';
327
+ let modelRequestId = 0;
328
+ let modelThinking = new Set();
329
+ let modelEffort = 'off';
330
+ const EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
331
+ const TITLE_GENERATION_CONCURRENCY = 3;
332
+ let generationChildren = new Map(); // target id -> ChildProcess
333
+ let generationCancelled = false;
334
+ let settingsPendingTarget = null;
335
+ let generationJobs = []; // queued [{ target, model, effort }]
336
+ let generationActive = false;
337
+ let handingOff = false;
338
+
339
+ function configuredPiModel() {
340
+ try {
341
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
342
+ if (settings.defaultProvider && settings.defaultModel) {
343
+ return `${settings.defaultProvider}/${settings.defaultModel}`;
344
+ }
345
+ } catch {}
346
+ return '';
347
+ }
348
+
349
+ function parseModelEntries(output) {
350
+ return output.split(/\r?\n/).slice(1).map(line => {
351
+ const cols = line.trim().split(/\s+/);
352
+ return cols.length >= 6 ? { id: `${cols[0]}/${cols[1]}`, thinking: cols[4] === 'yes' } : null;
353
+ }).filter(Boolean);
354
+ }
355
+
356
+ function parseModelList(output) {
357
+ return parseModelEntries(output).map(entry => entry.id);
358
+ }
359
+
360
+ function textFromContent(content) {
361
+ if (typeof content === 'string') return content.trim();
362
+ if (!Array.isArray(content)) return '';
363
+ return content.filter(x => x && x.type === 'text' && x.text).map(x => x.text.trim()).filter(Boolean).join('\n');
364
+ }
365
+
366
+ const TITLE_PROMPT = `Generate a durable, factual topic label for this coding session.
367
+ Describe what the session is about, not what the assistant completed or reported.
368
+ Do not summarize or quote the conversation.
369
+
370
+ Output exactly one line:
371
+ <3-8 word title>: <6-14 word subtitle>
372
+
373
+ Title requirements (highest priority):
374
+ - The title must stand alone because the subtitle may be truncated in the session list
375
+ - Use a base-form action such as Review, Rebase, Refactor, Resolve, Fix, Add, Remove, or Investigate
376
+ - Put the core subject, action, exact target, and distinguishing scope in the title
377
+ - Name human-readable identifiers whenever available: PR number or name, branch, class, function, filename, package, command, or issue
378
+ - Describe the central task or topic independent of its progress or completion state
379
+ - Never encode status or bookkeeping such as completed, done, finished, committed, updated, tests passed, or test counts
380
+ - Use factual, impersonal wording; never use I, we, you, I'll, I will, let's, or going to
381
+ - Never describe the assistant's plan, promise, tool usage, response, next step, or accomplishments
382
+ - Never copy a sentence from the conversation
383
+ - Omit commit hashes; unlike PR numbers, they are not useful human-readable identifiers
384
+ - When referring to a path, prefer the filename alone; include directories only when needed to disambiguate
385
+ - Count every slash-separated path component as one word toward the 3-8 word limit (for example, src/auth/Login.ts counts as three words)
386
+ - Prefer specific technical nouns over generic descriptions
387
+
388
+ Subtitle requirements:
389
+ - Use 6-14 words in one concise, factual imperative sentence
390
+ - Begin with a base-form action verb such as Add, Apply, Check, Extract, Implement, Inspect, Preserve, or Remove
391
+ - Use parallel imperative verb phrases when naming multiple actions
392
+ - Describe technical scope, constraints, or subject matter not already in the title
393
+ - Avoid report-style verbs such as covers, adds, identifies, resolves, or completed
394
+ - Do not report progress, completion, commit history, todo updates, or test counts unless testing is the session's central topic
395
+ - Use assistant evidence only to clarify the topic, target, and scope; ignore its status and process wording
396
+ - Do not repeat or rephrase the title
397
+
398
+ Examples:
399
+ Resolve sample-service Audit Findings: Apply focused refactors, tighten authorization, and bound cache growth.
400
+ Review desktop-client Concurrency Changes: Check lifecycle, thread safety, and event ownership.
401
+ Review PR #42 Token Refresh: Inspect refresh concurrency and add regression coverage.
402
+ Rebase feature/auth onto main: Resolve middleware conflicts and preserve session handling compatibility.
403
+ Refactor SessionManager Retry Logic: Extract backoff policy and remove duplicated state transitions.
404
+
405
+ General requirements:
406
+ - Use exactly one colon to separate the title and subtitle
407
+ - No quotation marks
408
+ - Output only the title and subtitle`;
409
+
410
+ function boundTitleContext(text, maxChars) {
411
+ if (text.length <= maxChars) return text;
412
+ const marker = '\n\n[earlier context omitted]\n\n';
413
+ if (maxChars <= marker.length) return text.slice(0, maxChars);
414
+ const available = maxChars - marker.length;
415
+ const head = Math.floor(available * 0.4);
416
+ return text.slice(0, head) + marker + text.slice(-(available - head));
417
+ }
418
+
419
+ function buildTitlePrompt(file, maxChars = 16000) {
420
+ const userRequests = [];
421
+ const assistantReports = [];
422
+ let sessionCwd = '';
423
+ try {
424
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
425
+ if (!line.trim()) continue;
426
+ let obj;
427
+ try { obj = JSON.parse(line); } catch { continue; }
428
+ if (obj.type === 'session' && !sessionCwd && typeof obj.cwd === 'string') {
429
+ sessionCwd = obj.cwd.trim();
430
+ continue;
431
+ }
432
+ if (obj.type !== 'message' || !obj.message) continue;
433
+ const { role, content, stopReason } = obj.message;
434
+ if (role !== 'user' && role !== 'assistant') continue;
435
+ const text = textFromContent(content);
436
+ if (!text) continue;
437
+ if (role === 'user') {
438
+ userRequests.push(`User request ${userRequests.length + 1}: ${text}`);
439
+ continue;
440
+ }
441
+ const hasToolCall = Array.isArray(content) && content.some(x => x && x.type === 'toolCall');
442
+ if (!hasToolCall && stopReason !== 'error' && stopReason !== 'aborted') {
443
+ assistantReports.push(`Assistant evidence ${assistantReports.length + 1}: ${text}`);
444
+ }
445
+ }
446
+ } catch (e) { throw new Error(`could not read session: ${e.message}`); }
447
+ if (!userRequests.length && !assistantReports.length) throw new Error('no conversation text found');
448
+
449
+ const userText = userRequests.join('\n\n');
450
+ const assistantText = assistantReports.join('\n\n');
451
+ const totalBudget = Math.max(0, maxChars);
452
+ let userBudget = Math.min(userText.length, Math.floor(totalBudget * 0.7));
453
+ let assistantBudget = Math.min(assistantText.length, totalBudget - userBudget);
454
+ let remaining = totalBudget - userBudget - assistantBudget;
455
+ const extraUser = Math.min(remaining, userText.length - userBudget);
456
+ userBudget += extraUser;
457
+ remaining -= extraUser;
458
+ assistantBudget += Math.min(remaining, assistantText.length - assistantBudget);
459
+
460
+ const sections = [];
461
+ const cwdParts = sessionCwd.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean);
462
+ if (cwdParts.length) sections.push(`Session context:\nWorking directory: ${cwdParts[cwdParts.length - 1]}`);
463
+ if (userText) sections.push(`User requests (authoritative task context):\n${boundTitleContext(userText, userBudget)}`);
464
+ if (assistantText) sections.push(`Assistant evidence (use only for topic, target, and scope; ignore completion and process wording):\n${boundTitleContext(assistantText, assistantBudget)}`);
465
+ return `${TITLE_PROMPT}\n\nConversation evidence:\n${sections.join('\n\n')}`;
466
+ }
467
+
468
+ function cleanGeneratedTitle(output) {
469
+ const safeOutput = String(output || '')
470
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
471
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '');
472
+ const lines = safeOutput.split(/\r?\n/)
473
+ .map(line => line.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim())
474
+ .filter(Boolean);
475
+ if (lines.length !== 1) throw new Error('model must return exactly one line');
476
+ const title = lines[0]
477
+ .replace(/^title\s*:\s*/i, '')
478
+ .replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, '')
479
+ .trim();
480
+ const separator = title.indexOf(':');
481
+ if (separator <= 0 || separator !== title.lastIndexOf(':') || separator === title.length - 1) {
482
+ throw new Error('model must return one title and subtitle separated by a colon');
483
+ }
484
+ return [...title].slice(0, 200).join('');
485
+ }
486
+
487
+ function titleGenerationArgs(model, effort, prompt) {
488
+ return [
489
+ '--print', '--no-session', '--no-context-files',
490
+ '--no-skills', '--no-prompt-templates', '--no-tools',
491
+ '--system-prompt', 'You generate durable factual topic labels, not completion reports or conversation summaries. Use base-form imperative actions in both the title and subtitle. Describe the session’s central task independent of progress, using assistant evidence only for the topic, target, and scope. Never copy status, process, accomplishments, plans, promises, tool usage, first-person wording, or report-style phrasing. Follow the user instructions exactly and output only the requested single-line title and subtitle.',
492
+ '--model', model, '--thinking', effort, prompt,
493
+ ];
494
+ }
495
+
496
+ function titleGenerationBlocker(target) {
497
+ return target && target.titleSource === 'manual'
498
+ ? 'manual title exists; clear it with e before generating'
499
+ : '';
500
+ }
279
501
 
280
502
  // ── Filtering by tab ──────────────────────────────────
281
503
  function visibleSessions() {
@@ -400,6 +622,7 @@ function visLen(s) {
400
622
  function render() {
401
623
  if (mode === 'edit') return renderEdit();
402
624
  if (mode === 'browse') return renderBrowse();
625
+ if (mode === 'model') return renderModelPicker();
403
626
  if (mode === 'details') return renderDetails();
404
627
  renderList();
405
628
  }
@@ -411,7 +634,8 @@ function renderList() {
411
634
 
412
635
  // Header
413
636
  out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' pi session bookmarks ' + A.R;
414
- out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R + '\n\n';
637
+ out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R;
638
+ out += '\n\n';
415
639
 
416
640
  // Tabs
417
641
  let tabLine = ' ';
@@ -459,14 +683,22 @@ function renderList() {
459
683
  const sel = i === cursor;
460
684
  const star = s.favored ? A.yel + '★' + A.R : ' ';
461
685
  const arrow = sel ? A.cyn + '▶' + A.R : ' ';
686
+ const currentJob = generationActive && generationChildren.has(s.id);
687
+ const queuedJob = generationActive && generationJobs.some(job => job.target.id === s.id);
462
688
  const ts = A.gry + fmtTs(s.mtime) + A.R;
463
689
  const cwd = A.mag + pad(shortCwd(s.effectiveCwd), 14) + A.R;
464
690
  const badge = s.isCurrent ? A.grn + A.B + '[NOW]' + A.R + ' ' : '';
465
- // marks a user-renamed session so it's clear the label isn't the prompt.
466
- const pen = s.titleOverride ? A.cyn + '✎' + A.R + ' ' : '';
467
- const titleMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0) - (s.titleOverride ? 2 : 0));
691
+ // Generation state and title provenance share one stable marker column.
692
+ const marker = currentJob
693
+ ? A.yel + '↻' + A.R + ' '
694
+ : queuedJob
695
+ ? A.gry + '…' + A.R + ' '
696
+ : s.titleOverride
697
+ ? A.cyn + (s.titleSource === 'llm' ? '◆' : '✎') + A.R + ' '
698
+ : '';
699
+ const titleMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0) - (marker ? 2 : 0));
468
700
  const title = trunc(s.title, titleMax);
469
- const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${pen}${title}`;
701
+ const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${marker}${title}`;
470
702
  if (sel) {
471
703
  const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
472
704
  out += A.bgBlu + rowText + pad2 + A.R + '\n';
@@ -489,6 +721,8 @@ function renderList() {
489
721
  + A.D + 'Enter' + A.R + ' current '
490
722
  + A.D + 'o' + A.R + ' original '
491
723
  + A.D + 'e' + A.R + ' edit name '
724
+ + A.D + 'g' + A.R + ' queue title '
725
+ + A.D + 'G' + A.R + ' settings '
492
726
  + A.D + 'p' + A.R + ' edit cwd '
493
727
  + A.D + 'd' + A.R + ' details '
494
728
  + A.D + '/' + A.R + ' search '
@@ -498,6 +732,207 @@ function renderList() {
498
732
  process.stdout.write(out);
499
733
  }
500
734
 
735
+ function filteredModels() {
736
+ const q = modelFilter.toLowerCase();
737
+ return q ? models.filter(m => m.toLowerCase().includes(q)) : models;
738
+ }
739
+
740
+ function renderModelPicker() {
741
+ const W = process.stdout.columns || 100;
742
+ const H = process.stdout.rows || 30;
743
+ const list = filteredModels();
744
+ if (modelSel >= list.length) modelSel = Math.max(0, list.length - 1);
745
+ let out = A.clr;
746
+ out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' title generation settings' + A.R + '\n\n';
747
+ if (settingsPendingTarget) out += ' ' + A.D + 'generate' + A.R + ' ' + trunc(settingsPendingTarget.title, W - 14) + '\n';
748
+ out += ' ' + A.D + 'filter ' + A.R + A.cyn + modelFilter + A.R + (modelFilter ? A.I + ' ' + A.R : '') + '\n';
749
+ out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
750
+ if (modelStatus) out += '\n ' + A.D + modelStatus + A.R + '\n';
751
+ else if (!list.length) out += '\n ' + A.D + '(no matching models)' + A.R + '\n';
752
+ else {
753
+ const effortWidths = EFFORT_LEVELS.map(level => level.length + 2);
754
+ const effortTotal = effortWidths.reduce((sum, width) => sum + width, 0);
755
+ const modelWidth = Math.max(12, W - effortTotal - 5);
756
+ out += ' ' + A.D + pad('model', modelWidth) + pad('effort', effortTotal) + A.R + '\n';
757
+ const usable = Math.max(3, H - 10);
758
+ const start = Math.max(0, Math.min(modelSel - Math.floor(usable / 2), list.length - usable));
759
+ for (let i = start; i < Math.min(list.length, start + usable); i++) {
760
+ const selected = i === modelSel;
761
+ const supportsEffort = modelThinking.has(list[i]);
762
+ let row = ' ' + (selected ? A.cyn + '▶' + A.R : ' ') + ' ';
763
+ row += (selected ? A.B : '') + pad(trunc(list[i], modelWidth), modelWidth) + (selected ? A.R : '');
764
+ for (let j = 0; j < EFFORT_LEVELS.length; j++) {
765
+ const level = EFFORT_LEVELS[j];
766
+ const available = supportsEffort || level === 'off';
767
+ const cell = pad(available ? level : '—', effortWidths[j]);
768
+ if (selected && level === modelEffort) row += A.bgBlu + A.B + cell + A.R;
769
+ else row += available ? A.D + cell + A.R : A.gry + cell + A.R;
770
+ }
771
+ out += row + '\n';
772
+ }
773
+ }
774
+ out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
775
+ out += ' ' + A.yel + 'Sends up to 16 KB of session text; provider charges may apply.' + A.R + '\n';
776
+ out += ' ' + A.D + '↑↓' + A.R + ' model ' + A.D + '←→' + A.R + ' effort '
777
+ + A.D + 'type' + A.R + ' filter ' + A.grn + A.B + 'Enter' + A.R + ' save '
778
+ + A.D + 'Esc' + A.R + ' cancel\n';
779
+ process.stdout.write(out);
780
+ }
781
+
782
+ function openGenerationSettings(pendingTarget = null) {
783
+ settingsPendingTarget = pendingTarget;
784
+ modelFilter = '';
785
+ modelSel = 0;
786
+ mode = 'model';
787
+ modelStatus = 'Loading models from pi…';
788
+ render();
789
+ let stdout = '', stderr = '';
790
+ const requestId = ++modelRequestId;
791
+ const child = spawn('pi', ['--list-models'], {
792
+ stdio: ['ignore', 'pipe', 'pipe'],
793
+ env: PI_ENV,
794
+ });
795
+ child.stdout.on('data', d => { stdout += d; });
796
+ child.stderr.on('data', d => { stderr += d; });
797
+ child.on('error', e => {
798
+ if (mode === 'model' && requestId === modelRequestId) {
799
+ modelStatus = `Could not run pi: ${e.message}`;
800
+ render();
801
+ }
802
+ });
803
+ child.on('close', code => {
804
+ if (mode !== 'model' || requestId !== modelRequestId) return;
805
+ const entries = code === 0 ? parseModelEntries(stdout) : [];
806
+ models = entries.map(entry => entry.id);
807
+ modelThinking = new Set(entries.filter(entry => entry.thinking).map(entry => entry.id));
808
+ modelStatus = models.length ? '' : `Could not list models${stderr.trim() ? `: ${stderr.trim()}` : ''}`;
809
+ const preferred = [metaSettings.titleModel, configuredPiModel()].filter(Boolean);
810
+ const idx = preferred.map(m => models.indexOf(m)).find(i => i >= 0);
811
+ modelSel = idx !== undefined ? idx : 0;
812
+ modelEffort = EFFORT_LEVELS.includes(metaSettings.titleThinkingLevel) ? metaSettings.titleThinkingLevel : 'off';
813
+ if (!modelThinking.has(models[modelSel])) modelEffort = 'off';
814
+ render();
815
+ });
816
+ }
817
+
818
+ function takeGenerationBatch(queue, activeCount, limit = TITLE_GENERATION_CONCURRENCY) {
819
+ const available = Math.max(0, limit - activeCount);
820
+ return queue.splice(0, available);
821
+ }
822
+
823
+ function enqueueTitle(target) {
824
+ if (!target) return;
825
+ const blocked = titleGenerationBlocker(target);
826
+ if (blocked) { notice = blocked; return; }
827
+ if (!metaSettings.titleModel) return openGenerationSettings(target);
828
+ if (generationChildren.has(target.id) || generationJobs.some(job => job.target.id === target.id)) return;
829
+
830
+ generationJobs.push({
831
+ target,
832
+ model: metaSettings.titleModel,
833
+ effort: metaSettings.titleThinkingLevel || 'off',
834
+ });
835
+ if (!generationActive) {
836
+ generationCancelled = false;
837
+ generationActive = true;
838
+ }
839
+ pumpTitleGeneration();
840
+ }
841
+
842
+ function finishGeneration() {
843
+ generationActive = false;
844
+ generationJobs = [];
845
+ generationChildren.clear();
846
+ generationCancelled = false;
847
+ if (!handingOff) render();
848
+ }
849
+
850
+ function terminateChild(child) {
851
+ child.kill('SIGTERM');
852
+ const force = setTimeout(() => {
853
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
854
+ }, 1000);
855
+ force.unref();
856
+ }
857
+
858
+ function cancelGeneration() {
859
+ if (!generationActive) return false;
860
+ generationCancelled = true;
861
+ generationJobs = [];
862
+ notice = 'title generation cancelled';
863
+ for (const child of generationChildren.values()) terminateChild(child);
864
+ if (generationChildren.size === 0) finishGeneration();
865
+ else if (!handingOff) render();
866
+ return true;
867
+ }
868
+
869
+ function pumpTitleGeneration() {
870
+ if (generationCancelled) {
871
+ if (generationChildren.size === 0) finishGeneration();
872
+ return;
873
+ }
874
+
875
+ while (generationJobs.length && generationChildren.size < TITLE_GENERATION_CONCURRENCY) {
876
+ const batch = takeGenerationBatch(generationJobs, generationChildren.size);
877
+ for (const job of batch) startTitleGeneration(job);
878
+ }
879
+
880
+ if (generationJobs.length === 0 && generationChildren.size === 0) finishGeneration();
881
+ else if (!handingOff) render();
882
+ }
883
+
884
+ function startTitleGeneration(job) {
885
+ let prompt;
886
+ try { prompt = buildTitlePrompt(job.target.file); }
887
+ catch (e) { notice = `title generation failed: ${e.message}`; return; }
888
+
889
+ let stdout = '', settled = false, timedOut = false;
890
+ const child = spawn('pi', titleGenerationArgs(job.model, job.effort, prompt), {
891
+ stdio: ['ignore', 'pipe', 'pipe'],
892
+ env: PI_ENV,
893
+ });
894
+ generationChildren.set(job.target.id, child);
895
+ const timer = setTimeout(() => {
896
+ if (!settled) { timedOut = true; terminateChild(child); }
897
+ }, 120000);
898
+ child.stdout.on('data', d => { stdout += d; });
899
+ child.stderr.resume();
900
+ const complete = () => {
901
+ if (settled) return;
902
+ settled = true;
903
+ clearTimeout(timer);
904
+ if (generationChildren.get(job.target.id) === child) generationChildren.delete(job.target.id);
905
+ pumpTitleGeneration();
906
+ };
907
+ child.on('error', () => {
908
+ notice = 'title generation failed: could not start pi';
909
+ complete();
910
+ });
911
+ child.on('close', code => {
912
+ if (settled) return;
913
+ if (code === 0 && !generationCancelled) {
914
+ try {
915
+ const title = cleanGeneratedTitle(stdout);
916
+ const current = overridesFor(job.target.id);
917
+ if (current.title && current.titleSource !== 'llm') {
918
+ notice = 'generated title discarded: manual title now exists';
919
+ return complete();
920
+ }
921
+ saveTitle(job.target.id, title, 'llm', job.model, job.effort);
922
+ notice = `generated title: ${trunc(title, 72)}`;
923
+ return complete();
924
+ } catch {
925
+ notice = 'title generation failed: model returned no usable title';
926
+ }
927
+ } else if (!generationCancelled) {
928
+ notice = timedOut
929
+ ? 'title generation timed out after 120 seconds'
930
+ : `title generation failed: pi exited ${code ?? 'without a status'}`;
931
+ }
932
+ complete();
933
+ });
934
+ }
935
+
501
936
  // Inline editor for a session's title or cwd. Renders a small focused panel
502
937
  // so the user always sees which session + field they're changing.
503
938
  function renderEdit() {
@@ -556,7 +991,11 @@ function renderDetails() {
556
991
  out += row('id', s.id, A.cyn);
557
992
  if (s.isCurrent) out += row('current', '● this is the attached pi session', A.grn);
558
993
  out += row('starred', s.favored ? '★ yes' : '☆ no', s.favored ? A.yel : A.gry);
559
- out += row('title', s.title + (s.titleOverride ? A.cyn + ' (custom)' + A.R : ''));
994
+ out += row('title', s.title + (s.titleOverride ? A.cyn + ` (${s.titleSource === 'llm' ? 'generated' : 'custom'})` + A.R : ''));
995
+ if (s.titleModel) {
996
+ out += row('title model', s.titleModel, A.cyn);
997
+ out += row('title effort', s.titleThinkingLevel || 'off', A.cyn);
998
+ }
560
999
  out += row('started', new Date(s.ts).toLocaleString());
561
1000
  out += row('updated', new Date(s.mtime).toLocaleString());
562
1001
  out += row('cwd', s.effectiveCwd + (s.cwdOverride ? A.cyn + ' (custom)' + A.R : ''), A.mag);
@@ -579,6 +1018,7 @@ function renderDetails() {
579
1018
  + A.D + 'o' + A.R + ' original settings '
580
1019
  + A.D + 'f' + A.R + ' star '
581
1020
  + A.D + 'e' + A.R + ' edit name '
1021
+ + A.D + 'g' + A.R + ' generate title '
582
1022
  + A.D + 'p' + A.R + ' edit cwd '
583
1023
  + A.D + 'c' + A.R + ' copy id '
584
1024
  + A.D + 'Esc/d/q' + A.R + ' back' + A.R + '\n';
@@ -611,9 +1051,16 @@ function setupInput() {
611
1051
 
612
1052
  process.stdin.on('keypress', (str, key) => {
613
1053
  try {
1054
+ if (generationActive && key.ctrl && key.name === 'c') return quit();
1055
+ if (generationActive && (mode === 'list' || mode === 'details') && (key.name === 'escape' || key.name === 'q')) {
1056
+ if (generationCancelled) return quit();
1057
+ cancelGeneration();
1058
+ return;
1059
+ }
614
1060
  if (mode === 'filter') handleFilter(str, key);
615
1061
  else if (mode === 'edit') handleEdit(str, key);
616
1062
  else if (mode === 'browse') handleBrowse(str, key);
1063
+ else if (mode === 'model') handleModelPicker(str, key);
617
1064
  else if (mode === 'details') handleDetails(str, key);
618
1065
  else handleList(str, key);
619
1066
  render();
@@ -637,7 +1084,10 @@ function handleList(str, key) {
637
1084
  const k = key.name;
638
1085
 
639
1086
  if (key.ctrl && k === 'c') return quit();
640
- if (k === 'q') return quit();
1087
+ if (k === 'q') {
1088
+ if (filter) { filter = ''; cursor = 0; return; }
1089
+ return quit();
1090
+ }
641
1091
  // Esc: clears filter first (one-step back-out), otherwise quits.
642
1092
  // Matches `q` so muscle memory works either way.
643
1093
  if (k === 'escape') {
@@ -670,6 +1120,8 @@ function handleList(str, key) {
670
1120
  }
671
1121
  else if (k === 'd') { if (list[cursor]) mode = 'details'; }
672
1122
  else if (k === 'e') startEdit(list[cursor], 'title');
1123
+ else if (k === 'g' && (str === 'G' || key.shift)) openGenerationSettings();
1124
+ else if (k === 'g') enqueueTitle(list[cursor]);
673
1125
  else if (k === 'p') startBrowse(list[cursor]);
674
1126
  else if (str === '/') { mode = 'filter'; }
675
1127
  else if (k === 'r') { sessions = scanSessions(); cursor = 0; }
@@ -681,7 +1133,8 @@ function handleList(str, key) {
681
1133
 
682
1134
  function handleFilter(str, key) {
683
1135
  const k = key.name;
684
- if (k === 'return' || k === 'escape') { mode = 'list'; return; }
1136
+ if (k === 'return') { mode = 'list'; return; }
1137
+ if (k === 'escape' || k === 'q') { filter = ''; cursor = 0; mode = 'list'; return; }
685
1138
  if (k === 'backspace') { filter = filter.slice(0, -1); cursor = 0; return; }
686
1139
  if (key.ctrl && k === 'c') return quit();
687
1140
  if (key.ctrl && k === 'u') { filter = ''; cursor = 0; return; }
@@ -704,9 +1157,52 @@ function handleDetails(str, key) {
704
1157
  tryCopy(s.id);
705
1158
  }
706
1159
  if (k === 'e' && s) startEdit(s, 'title');
1160
+ if (k === 'g' && (str === 'G' || key.shift)) openGenerationSettings();
1161
+ else if (k === 'g' && s) enqueueTitle(s);
707
1162
  if (k === 'p' && s) startBrowse(s);
708
1163
  }
709
1164
 
1165
+ function handleModelPicker(str, key) {
1166
+ const k = key.name;
1167
+ const list = filteredModels();
1168
+ if (key.ctrl && k === 'c') return quit();
1169
+ if (k === 'escape' || k === 'q') {
1170
+ modelRequestId++;
1171
+ mode = 'list'; settingsPendingTarget = null; modelStatus = ''; return;
1172
+ }
1173
+ if (k === 'up' || k === 'k') {
1174
+ modelSel = Math.max(0, modelSel - 1);
1175
+ if (!modelThinking.has(list[modelSel])) modelEffort = 'off';
1176
+ return;
1177
+ }
1178
+ if (k === 'down' || k === 'j') {
1179
+ modelSel = Math.min(list.length - 1, modelSel + 1);
1180
+ if (!modelThinking.has(list[modelSel])) modelEffort = 'off';
1181
+ return;
1182
+ }
1183
+ if ((k === 'left' || k === 'right') && modelThinking.has(list[modelSel])) {
1184
+ const delta = k === 'right' ? 1 : -1;
1185
+ const idx = EFFORT_LEVELS.indexOf(modelEffort);
1186
+ modelEffort = EFFORT_LEVELS[(idx + delta + EFFORT_LEVELS.length) % EFFORT_LEVELS.length];
1187
+ return;
1188
+ }
1189
+ if (k === 'return') {
1190
+ if (!modelStatus.startsWith('Loading') && list[modelSel]) {
1191
+ metaSettings.titleModel = list[modelSel];
1192
+ metaSettings.titleThinkingLevel = modelEffort;
1193
+ saveMeta();
1194
+ const pending = settingsPendingTarget;
1195
+ settingsPendingTarget = null;
1196
+ mode = 'list';
1197
+ if (pending) enqueueTitle(pending);
1198
+ }
1199
+ return;
1200
+ }
1201
+ if (k === 'backspace') { modelFilter = modelFilter.slice(0, -1); modelSel = 0; return; }
1202
+ if (key.ctrl && k === 'u') { modelFilter = ''; modelSel = 0; return; }
1203
+ if (str && str.length === 1 && !key.ctrl) { modelFilter += str; modelSel = 0; }
1204
+ }
1205
+
710
1206
  // Open the inline editor for a session's title or cwd, seeded with the
711
1207
  // current override (or recorded cwd, so the user can tweak rather than retype).
712
1208
  function startEdit(s, field) {
@@ -723,7 +1219,10 @@ function handleEdit(str, key) {
723
1219
  if (key.ctrl && k === 'c') return quit();
724
1220
  if (k === 'escape') { mode = 'list'; editTarget = null; return; }
725
1221
  if (k === 'return') {
726
- if (editTarget) setOverride(editTarget.id, editField, editBuffer);
1222
+ if (editTarget) {
1223
+ if (editField === 'title') saveTitle(editTarget.id, editBuffer, 'manual');
1224
+ else setOverride(editTarget.id, editField, editBuffer);
1225
+ }
727
1226
  mode = 'list';
728
1227
  editTarget = null;
729
1228
  return;
@@ -984,6 +1483,38 @@ function loadResumeDefaults(settingsFile = SETTINGS_FILE) {
984
1483
  } catch { return null; }
985
1484
  }
986
1485
 
1486
+ function loadSessionSettings(file) {
1487
+ try {
1488
+ const entries = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap(line => {
1489
+ try { return [JSON.parse(line)]; } catch { return []; }
1490
+ }).filter(entry => entry && entry.type !== 'session');
1491
+ const byId = new Map(entries.filter(entry => entry.id).map(entry => [entry.id, entry]));
1492
+ const branch = [];
1493
+ const visited = new Set();
1494
+ let entry = entries[entries.length - 1];
1495
+ while (entry && !visited.has(entry.id)) {
1496
+ branch.push(entry);
1497
+ visited.add(entry.id);
1498
+ entry = entry.parentId ? byId.get(entry.parentId) : undefined;
1499
+ }
1500
+ branch.reverse();
1501
+
1502
+ let model = '';
1503
+ let thinking = '';
1504
+ for (const item of branch) {
1505
+ if (item.type === 'thinking_level_change' && typeof item.thinkingLevel === 'string') {
1506
+ thinking = item.thinkingLevel;
1507
+ } else if (item.type === 'model_change' && typeof item.provider === 'string' && typeof item.modelId === 'string') {
1508
+ model = `${item.provider}/${item.modelId}`;
1509
+ } else if (item.type === 'message' && item.message?.role === 'assistant'
1510
+ && typeof item.message.provider === 'string' && typeof item.message.model === 'string') {
1511
+ model = `${item.message.provider}/${item.message.model}`;
1512
+ }
1513
+ }
1514
+ return { model, thinking };
1515
+ } catch { return null; }
1516
+ }
1517
+
987
1518
  function buildResumeArgs(s, useCurrentDefaults = true, settingsFile = SETTINGS_FILE) {
988
1519
  const args = ['--session', s.id, '--session-dir', path.dirname(s.file)];
989
1520
  const defaults = useCurrentDefaults && loadResumeDefaults(settingsFile);
@@ -992,23 +1523,70 @@ function buildResumeArgs(s, useCurrentDefaults = true, settingsFile = SETTINGS_F
992
1523
  return args;
993
1524
  }
994
1525
 
1526
+ function buildSelection(s, useCurrentDefaults = true, repaired = 0, settingsFile = SETTINGS_FILE) {
1527
+ const defaults = loadResumeDefaults(settingsFile);
1528
+ const recorded = useCurrentDefaults ? null : loadSessionSettings(s.file);
1529
+ const settings = useCurrentDefaults ? defaults : {
1530
+ model: recorded?.model || defaults?.model || '',
1531
+ thinking: recorded?.thinking || defaults?.thinking || '',
1532
+ };
1533
+ return {
1534
+ version: 1,
1535
+ sessionPath: path.resolve(s.file),
1536
+ resumeMode: useCurrentDefaults ? 'defaults' : 'session',
1537
+ ...(s.cwdOverride ? { cwdOverride: s.cwdOverride } : {}),
1538
+ ...(settings?.model ? { model: settings.model } : {}),
1539
+ ...(settings?.thinking ? { thinking: settings.thinking } : {}),
1540
+ ...(repaired > 0 ? { repaired } : {}),
1541
+ };
1542
+ }
1543
+
1544
+ function leaveTui() {
1545
+ process.stdout.write(A.exitAlt + A.showC);
1546
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
1547
+ process.stdin.pause();
1548
+ }
1549
+
1550
+ function returnSelection(selection) {
1551
+ try {
1552
+ fs.writeSync(SELECT_FD, `${JSON.stringify(selection)}\n`);
1553
+ } catch (error) {
1554
+ throw new Error(`could not return selection to pi: ${error.message}`);
1555
+ }
1556
+ }
1557
+
995
1558
  function resumeSession(s, useCurrentDefaults = true) {
996
1559
  if (!s) return;
997
- // Heal orphaned tool calls before handing off, so resume can't crash the
998
- // spawned pi (see healOrphanedToolCalls).
1560
+ // A resumed pi or the parent extension takes over this terminal, so stop
1561
+ // background title work and suppress its asynchronous final render.
1562
+ handingOff = true;
1563
+ cancelGeneration();
1564
+
1565
+ // Selecting the already-active session must not rewrite its live transcript.
1566
+ if (SELECT_FD !== null && s.isCurrent) {
1567
+ leaveTui();
1568
+ try { returnSelection(buildSelection(s, useCurrentDefaults)); }
1569
+ catch (error) { console.error(`pisesh: ${error.message}`); process.exit(1); }
1570
+ process.exit(0);
1571
+ }
1572
+
1573
+ // Pi still preserves a valid unfinished toolCall without adding its missing
1574
+ // result, so repair it before either native switching or standalone resume.
999
1575
  let repaired;
1000
1576
  try { repaired = healOrphanedToolCalls(s.file); }
1001
1577
  catch (error) {
1002
- process.stdout.write(A.exitAlt + A.showC);
1003
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
1578
+ leaveTui();
1004
1579
  console.error(`pisesh: ${error.message}`);
1005
1580
  process.exit(1);
1006
1581
  }
1007
- // Leave alt screen + show cursor so the spawned pi takes over a clean
1008
- // main-buffer terminal (it will manage its own alt screen).
1009
- process.stdout.write(A.exitAlt + A.showC);
1010
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
1011
- process.stdin.pause();
1582
+ leaveTui();
1583
+
1584
+ if (SELECT_FD !== null) {
1585
+ try { returnSelection(buildSelection(s, useCurrentDefaults, repaired)); }
1586
+ catch (error) { console.error(`pisesh: ${error.message}`); process.exit(1); }
1587
+ process.exit(0);
1588
+ }
1589
+
1012
1590
  if (repaired > 0) {
1013
1591
  console.log(`pisesh: repaired ${repaired} orphaned session entr${repaired === 1 ? 'y' : 'ies'}; backup: ${s.file}.bak-orphanheal`);
1014
1592
  }
@@ -1019,6 +1597,7 @@ function resumeSession(s, useCurrentDefaults = true) {
1019
1597
  const child = spawn('pi', buildResumeArgs(s, useCurrentDefaults), {
1020
1598
  stdio: 'inherit',
1021
1599
  cwd: targetCwd && fs.existsSync(targetCwd) ? targetCwd : process.cwd(),
1600
+ env: PI_ENV,
1022
1601
  });
1023
1602
  child.on('exit', code => process.exit(code || 0));
1024
1603
  child.on('error', err => {
@@ -1028,6 +1607,8 @@ function resumeSession(s, useCurrentDefaults = true) {
1028
1607
  }
1029
1608
 
1030
1609
  function quit() {
1610
+ handingOff = true;
1611
+ for (const child of generationChildren.values()) child.kill('SIGKILL');
1031
1612
  // Restore user's pre-pisesh terminal (alt screen exits + cursor back).
1032
1613
  // No explicit clear needed — the terminal restoration handles it and the
1033
1614
  // jump back to the previous prompt feels instant.
@@ -1058,13 +1639,17 @@ TUI keys:
1058
1639
  Enter resume with current default model and thinking settings
1059
1640
  o resume with the session-recorded model and thinking settings
1060
1641
  e edit name (custom display title)
1642
+ g queue title generation with the saved model + effort
1643
+ (first use opens settings; active/queued threads are ignored)
1644
+ G open title generation model + effort settings
1061
1645
  p edit cwd — arrow-key directory browser
1062
1646
  (↑↓ move, →/Enter open dir, ← parent, s select, Esc cancel)
1063
1647
  sets the working directory used on resume / "Here" tab
1064
1648
  d show details
1065
1649
  / search (id / project / prompt / title)
1066
1650
  r rescan session files
1067
- q / Esc / Ctrl-C quit
1651
+ q / Esc cancel generation / clear search first; press again to quit
1652
+ Ctrl-C cancel generation and quit immediately
1068
1653
 
1069
1654
  Tabs:
1070
1655
  Here only sessions whose cwd matches the directory pisesh was
@@ -1134,4 +1719,19 @@ function main() {
1134
1719
  }
1135
1720
 
1136
1721
  if (require.main === module) main();
1137
- else module.exports = { buildResumeArgs, cleanStaleFavorites, healOrphanedToolCalls, loadResumeDefaults };
1722
+ else module.exports = {
1723
+ buildResumeArgs,
1724
+ buildSelection,
1725
+ buildTitlePrompt,
1726
+ cleanGeneratedTitle,
1727
+ cleanStaleFavorites,
1728
+ healOrphanedToolCalls,
1729
+ loadResumeDefaults,
1730
+ loadSessionSettings,
1731
+ parseModelEntries,
1732
+ parseModelList,
1733
+ piAgentEnv,
1734
+ takeGenerationBatch,
1735
+ titleGenerationArgs,
1736
+ titleGenerationBlocker,
1737
+ };
@@ -1,97 +1,269 @@
1
1
  /**
2
2
  * pisesh slash command
3
3
  *
4
- * Registers `/sesh` inside pi.
5
- *
6
- * Behavior:
7
- * 1. Pauses pi's TUI (releases the terminal)
8
- * 2. Spawns the external `pisesh` TUI (bookmark/resume picker)
9
- * 3. When pisesh exits — whether the user resumed a nested session and quit it,
10
- * or just pressed `q` — control returns to the original pi session and the
11
- * TUI is restored.
12
- *
13
- * Bundled CLI tool: ../bin/pisesh (single-file Node TUI, no deps)
14
- * Favorites file: ~/.pi/agent/favorites.json
4
+ * `/sesh` temporarily hands the terminal to the bundled picker. The picker
5
+ * returns a session path on a private fd; this extension then asks pi to switch
6
+ * its current runtime. Standalone `pisesh` still launches pi itself.
15
7
  */
16
8
 
17
9
  import { spawn } from "node:child_process";
18
10
  import path from "node:path";
11
+ import type { Readable } from "node:stream";
19
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
13
 
21
14
  // Static package path, no user-controlled segments.
22
15
  const PISESH_CLI = path.resolve(__dirname, "../bin/pisesh"); // pi-lens-ignore: ts-path-traversal
16
+ const THINKING_LEVELS = [
17
+ "off",
18
+ "minimal",
19
+ "low",
20
+ "medium",
21
+ "high",
22
+ "xhigh",
23
+ "max",
24
+ ] as const;
25
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
26
+
27
+ type PiseshSelection = {
28
+ version: 1;
29
+ sessionPath: string;
30
+ resumeMode: "defaults" | "session";
31
+ cwdOverride?: string;
32
+ model?: string;
33
+ thinking?: ThinkingLevel;
34
+ repaired?: number;
35
+ };
36
+
37
+ type PickerResult = {
38
+ code: number | null;
39
+ selection?: PiseshSelection;
40
+ error?: string;
41
+ };
42
+
43
+ type PendingSwitch = Pick<
44
+ PiseshSelection,
45
+ "sessionPath" | "cwdOverride" | "model" | "thinking" | "repaired"
46
+ >;
23
47
 
24
- function runPisesh(
25
- currentSessionId: string | undefined,
26
- ): Promise<number | null> {
48
+ const processState = globalThis as typeof globalThis & {
49
+ __piseshPendingSwitch?: PendingSwitch;
50
+ };
51
+
52
+ function parseSelection(raw: string): PiseshSelection | undefined {
53
+ if (!raw.trim()) return undefined;
54
+
55
+ let value: unknown;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ } catch {
59
+ throw new Error("picker returned invalid JSON");
60
+ }
61
+ if (!value || typeof value !== "object") {
62
+ throw new Error("picker returned a non-object selection");
63
+ }
64
+ const data = value as Record<string, unknown>;
65
+ if (
66
+ data.version !== 1 ||
67
+ typeof data.sessionPath !== "string" ||
68
+ !path.isAbsolute(data.sessionPath) ||
69
+ (data.resumeMode !== "defaults" && data.resumeMode !== "session")
70
+ ) {
71
+ throw new Error("picker returned an invalid session selection");
72
+ }
73
+ if (data.cwdOverride !== undefined && typeof data.cwdOverride !== "string") {
74
+ throw new Error("picker returned an invalid cwd override");
75
+ }
76
+ if (data.model !== undefined && typeof data.model !== "string") {
77
+ throw new Error("picker returned an invalid model");
78
+ }
79
+ if (
80
+ data.thinking !== undefined &&
81
+ !THINKING_LEVELS.includes(data.thinking as ThinkingLevel)
82
+ ) {
83
+ throw new Error("picker returned an invalid thinking level");
84
+ }
85
+ if (
86
+ data.repaired !== undefined &&
87
+ (!Number.isInteger(data.repaired) || (data.repaired as number) < 0)
88
+ ) {
89
+ throw new Error("picker returned an invalid repair count");
90
+ }
91
+ return data as PiseshSelection;
92
+ }
93
+
94
+ function runPisesh(currentSessionId: string | undefined): Promise<PickerResult> {
27
95
  return new Promise((resolve) => {
28
- // stdio:"inherit" hands the real TTY to pisesh. pi's tui.stop() has
29
- // already detached so this is safe.
30
- // PISESH_CURRENT_SESSION lets pisesh flag the row that belongs to the
31
- // pi instance that just spawned it (rendered with a [NOW] badge).
96
+ let output = "";
97
+ let settled = false;
98
+ const finish = (result: PickerResult) => {
99
+ if (settled) return;
100
+ settled = true;
101
+ resolve(result);
102
+ };
103
+
104
+ // fd 3 carries one small JSON result while stdin/stdout/stderr remain the
105
+ // real terminal used by the full-screen picker.
32
106
  const child = spawn("node", [PISESH_CLI], {
33
- stdio: "inherit",
107
+ stdio: ["inherit", "inherit", "inherit", "pipe"],
34
108
  env: {
35
109
  ...process.env,
36
- // Forward pi's cwd so pisesh's "Here" tab can show only the sessions
37
- // that belong to the directory this pi instance is attached to.
110
+ PISESH_SELECT_FD: "3",
38
111
  PISESH_CWD: process.cwd(),
39
112
  ...(currentSessionId
40
113
  ? { PISESH_CURRENT_SESSION: currentSessionId }
41
114
  : {}),
42
115
  },
43
116
  });
44
- child.on("exit", (code) => resolve(code));
45
- child.on("error", (err) => {
46
- // Surface a readable error in the terminal before we re-render.
117
+ const resultPipe = child.stdio[3] as Readable | null;
118
+ resultPipe?.setEncoding("utf8");
119
+ resultPipe?.on("data", (chunk: string) => {
120
+ output += chunk;
121
+ });
122
+ child.on("close", (code) => {
123
+ if (code !== 0) return finish({ code });
124
+ try {
125
+ finish({ code, selection: parseSelection(output) });
126
+ } catch (error) {
127
+ finish({
128
+ code,
129
+ error: error instanceof Error ? error.message : String(error),
130
+ });
131
+ }
132
+ });
133
+ child.on("error", (error) => {
47
134
  process.stdout.write(
48
- `\x1b[31mpisesh failed to launch: ${err.message}\x1b[0m\n`,
135
+ `\x1b[31mpisesh failed to launch: ${error.message}\x1b[0m\n`,
49
136
  );
50
- resolve(127);
137
+ finish({ code: 127, error: error.message });
51
138
  });
52
139
  });
53
140
  }
54
141
 
142
+ function sameSession(left: string | undefined, right: string): boolean {
143
+ return left ? path.resolve(left) === path.resolve(right) : false;
144
+ }
145
+
55
146
  export default function (pi: ExtensionAPI) {
147
+ // A successful switch loads a fresh extension instance before the old command
148
+ // returns. Plain pending data on globalThis lets that new instance apply the
149
+ // selected model and thinking without touching stale pre-switch pi/ctx objects.
150
+ pi.on("session_start", async (event, ctx) => {
151
+ const pending = processState.__piseshPendingSwitch;
152
+ if (
153
+ event.reason !== "resume" ||
154
+ !pending ||
155
+ !sameSession(ctx.sessionManager.getSessionFile(), pending.sessionPath)
156
+ ) {
157
+ return;
158
+ }
159
+ processState.__piseshPendingSwitch = undefined;
160
+
161
+ if (pending.model) {
162
+ const separator = pending.model.indexOf("/");
163
+ const model =
164
+ separator > 0
165
+ ? ctx.modelRegistry.find(
166
+ pending.model.slice(0, separator),
167
+ pending.model.slice(separator + 1),
168
+ )
169
+ : undefined;
170
+ if (!model || !(await pi.setModel(model))) {
171
+ ctx.ui.notify(
172
+ `Could not apply resume model: ${pending.model}`,
173
+ "warning",
174
+ );
175
+ }
176
+ }
177
+ if (pending.thinking) {
178
+ pi.setThinkingLevel(
179
+ pending.thinking as Parameters<typeof pi.setThinkingLevel>[0],
180
+ );
181
+ }
182
+ if (pending.repaired) {
183
+ ctx.ui.notify(
184
+ `Repaired ${pending.repaired} interrupted tool call${pending.repaired === 1 ? "" : "s"} before resume`,
185
+ "warning",
186
+ );
187
+ }
188
+ if (
189
+ pending.cwdOverride &&
190
+ path.resolve(ctx.cwd) !== path.resolve(pending.cwdOverride)
191
+ ) {
192
+ ctx.ui.notify(
193
+ "This pi version did not apply the selected cwd override; update pi to a version that supports it",
194
+ "warning",
195
+ );
196
+ }
197
+ });
198
+
56
199
  pi.registerCommand("sesh", {
57
200
  description: "Browse, star, and resume pi sessions (opens pisesh TUI)",
58
201
  handler: async (_args, ctx) => {
59
- if (!ctx.hasUI) {
60
- ctx.ui?.notify?.("/sesh requires interactive UI", "warning");
202
+ if (ctx.mode !== "tui") {
203
+ ctx.ui.notify("/sesh requires pi's interactive TUI", "warning");
61
204
  return;
62
205
  }
63
206
 
64
- let currentId: string | undefined;
65
- try {
66
- currentId = ctx.sessionManager?.getSessionId?.();
67
- } catch {
68
- currentId = undefined;
69
- }
70
-
71
- const code = await ctx.ui.custom<number | null>(
72
- (tui, _theme, _kb, done) => {
73
- // Hand over the terminal
207
+ const currentId = ctx.sessionManager.getSessionId();
208
+ const result = await ctx.ui.custom<PickerResult>(
209
+ (tui, _theme, _keybindings, done) => {
74
210
  tui.stop();
75
211
  process.stdout.write("\x1b[2J\x1b[H");
76
-
77
- runPisesh(currentId).then((exitCode) => {
78
- // Restore pi's TUI
212
+ void runPisesh(currentId).then((pickerResult) => {
79
213
  tui.start();
80
214
  tui.requestRender(true);
81
- done(exitCode);
215
+ done(pickerResult);
82
216
  });
83
-
84
- // Return a no-op component (custom() requires one synchronously)
85
217
  return { render: () => [], invalidate: () => {} };
86
218
  },
87
219
  );
88
220
 
89
- if (code === 0 || code === null) {
90
- ctx.ui.notify("Returned from pisesh", "info");
91
- } else if (code === 127) {
92
- ctx.ui.notify("pisesh failed to launch", "error");
93
- } else {
94
- ctx.ui.notify(`pisesh exited with code ${code}`, "warning");
221
+ if (!result) return;
222
+ if (result.error) {
223
+ ctx.ui.notify(`pisesh: ${result.error}`, "error");
224
+ return;
225
+ }
226
+ if (result.code !== 0 && result.code !== null) {
227
+ ctx.ui.notify(`pisesh exited with code ${result.code}`, "warning");
228
+ return;
229
+ }
230
+ const selection = result.selection;
231
+ if (!selection) return;
232
+
233
+ const currentFile = ctx.sessionManager.getSessionFile();
234
+ if (sameSession(currentFile, selection.sessionPath)) {
235
+ ctx.ui.notify("That session is already active", "info");
236
+ return;
237
+ }
238
+
239
+ const pending: PendingSwitch = {
240
+ sessionPath: selection.sessionPath,
241
+ cwdOverride: selection.cwdOverride,
242
+ model: selection.model,
243
+ thinking: selection.thinking,
244
+ repaired: selection.repaired,
245
+ };
246
+ processState.__piseshPendingSwitch = pending;
247
+
248
+ try {
249
+ const switchSession = ctx.switchSession as (
250
+ sessionPath: string,
251
+ options?: { cwdOverride?: string },
252
+ ) => Promise<{ cancelled: boolean }>;
253
+ const switched = await switchSession(
254
+ selection.sessionPath,
255
+ selection.cwdOverride
256
+ ? { cwdOverride: selection.cwdOverride }
257
+ : undefined,
258
+ );
259
+ if (switched.cancelled) {
260
+ processState.__piseshPendingSwitch = undefined;
261
+ ctx.ui.notify("Resume cancelled", "info");
262
+ }
263
+ } finally {
264
+ if (processState.__piseshPendingSwitch === pending) {
265
+ processState.__piseshPendingSwitch = undefined;
266
+ }
95
267
  }
96
268
  },
97
269
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pisesh",
3
- "version": "0.1.13",
3
+ "version": "0.3.0",
4
4
  "description": "Bookmark, search, and resume pi coding-agent sessions with a fast keyboard-driven TUI.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,7 +27,7 @@
27
27
  "author": "Blue-B <source_vs@naver.com>",
28
28
  "type": "commonjs",
29
29
  "bin": {
30
- "pisesh": "./bin/pisesh"
30
+ "pisesh": "bin/pisesh"
31
31
  },
32
32
  "files": [
33
33
  "bin/",