dsh-ssh-tui 0.5.9 → 0.6.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.
Files changed (56) hide show
  1. package/README.en.md +28 -5
  2. package/README.md +76 -6
  3. package/lib/attach.js +156 -0
  4. package/lib/attach.js.map +1 -0
  5. package/lib/commands.js +73 -0
  6. package/lib/commands.js.map +1 -0
  7. package/lib/diag.js +292 -0
  8. package/lib/diag.js.map +1 -0
  9. package/lib/dialogs.js +82 -0
  10. package/lib/dialogs.js.map +1 -0
  11. package/lib/display-sock.js +304 -86
  12. package/lib/display-sock.js.map +1 -1
  13. package/lib/dsh-compat.js +25 -4
  14. package/lib/dsh-compat.js.map +1 -1
  15. package/lib/i18n/en.js +55 -0
  16. package/lib/i18n/en.js.map +1 -1
  17. package/lib/i18n/index.js +2 -0
  18. package/lib/i18n/index.js.map +1 -1
  19. package/lib/i18n/zh.js +55 -0
  20. package/lib/i18n/zh.js.map +1 -1
  21. package/lib/index.js +75 -73
  22. package/lib/index.js.map +1 -1
  23. package/lib/paint.js +20 -38
  24. package/lib/paint.js.map +1 -1
  25. package/lib/picker.js +152 -37
  26. package/lib/picker.js.map +1 -1
  27. package/lib/rows.js +120 -0
  28. package/lib/rows.js.map +1 -0
  29. package/lib/session-index.js +4 -1
  30. package/lib/session-index.js.map +1 -1
  31. package/lib/session-list.js +372 -167
  32. package/lib/session-list.js.map +1 -1
  33. package/lib/session-lock.js +20 -9
  34. package/lib/session-lock.js.map +1 -1
  35. package/lib/stats.js +136 -0
  36. package/lib/stats.js.map +1 -0
  37. package/lib/terminal-input.js +470 -0
  38. package/lib/terminal-input.js.map +1 -0
  39. package/lib/tui.js +257 -311
  40. package/lib/tui.js.map +1 -1
  41. package/lib/types/attach.d.ts +106 -0
  42. package/lib/types/commands.d.ts +103 -0
  43. package/lib/types/diag.d.ts +78 -0
  44. package/lib/types/dialogs.d.ts +79 -0
  45. package/lib/types/display-sock.d.ts +59 -3
  46. package/lib/types/dsh-compat.d.ts +7 -0
  47. package/lib/types/i18n/index.d.ts +4 -0
  48. package/lib/types/index.d.ts +8 -4
  49. package/lib/types/paint.d.ts +6 -3
  50. package/lib/types/picker.d.ts +27 -7
  51. package/lib/types/rows.d.ts +69 -0
  52. package/lib/types/session-list.d.ts +43 -3
  53. package/lib/types/stats.d.ts +98 -0
  54. package/lib/types/terminal-input.d.ts +164 -0
  55. package/lib/types/tui.d.ts +33 -8
  56. package/package.json +11 -5
@@ -59,10 +59,29 @@ export function formatSessionTime(timestamp) {
59
59
  const pad = (value) => String(value).padStart(2, '0');
60
60
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
61
61
  }
62
- /** Whether one durable event is a user-authored message. */
62
+ /**
63
+ * Whether one durable event is a message put in front of the model.
64
+ *
65
+ * Any source counts, not just `kind: 'user'`: cron continuations, goal nudges
66
+ * and context snapshots are plugin-authored `user/message` events, and a
67
+ * session carrying one is not a crashed boot. Treating those as "no input"
68
+ * made a live mid-turn session look blank — and a blank verdict deletes the
69
+ * session's directory and stops its Host.
70
+ */
63
71
  function isUserMessageEvent(event) {
64
- const candidate = event;
65
- return candidate.type === 'user/message' && candidate.data?.source?.kind === 'user';
72
+ return event.type === 'user/message';
73
+ }
74
+ /** A turn that started and never ended: the session is mid-work, never blank. */
75
+ function hasUnfinishedTurn(events) {
76
+ let open = 0;
77
+ for (const event of events) {
78
+ const type = event.type;
79
+ if (type === 'turn/start')
80
+ open += 1;
81
+ else if (type === 'turn/end')
82
+ open -= 1;
83
+ }
84
+ return open > 0;
66
85
  }
67
86
  /**
68
87
  * Whether the session ever produced a model reply. A failed agent request
@@ -79,13 +98,41 @@ function sessionHasReply(events) {
79
98
  return false;
80
99
  });
81
100
  }
101
+ /**
102
+ * The persisted generated title, else the user's first input (trimmed to one
103
+ * short line). `undefined` means the log carries no name of its own and the
104
+ * caller's fallback (usually the id) has to stand in.
105
+ */
106
+ function labelFromEvents(events) {
107
+ const titleEvent = [...events].reverse()
108
+ .find(event => event.type === 'session/title');
109
+ const title = titleEvent === undefined
110
+ ? undefined
111
+ : titleEvent.data?.title;
112
+ if (title !== undefined && title !== '')
113
+ return title;
114
+ const firstUserMessage = events.find(event => isUserMessageEvent(event));
115
+ if (firstUserMessage === undefined)
116
+ return undefined;
117
+ const text = Array.from((firstUserMessage.data?.content ?? [])
118
+ .map((block) => {
119
+ const candidate = block;
120
+ return candidate.type === 'text' && typeof candidate.text === 'string' ? candidate.text : '';
121
+ })
122
+ .join(' ')
123
+ .replace(/\s+/gu, ' ')
124
+ .trim())
125
+ .slice(0, 80)
126
+ .join('');
127
+ return text === '' ? undefined : text;
128
+ }
82
129
  /**
83
130
  * A blank session never saw user input nor a model reply — a boot that died
84
131
  * before doing anything. Such sessions are deleted (never listed as
85
132
  * resumable) so crashed launches stop littering the picker with raw ids.
86
133
  */
87
- function isBlankSession(hasUserInput, hasReply) {
88
- return !hasUserInput && !hasReply;
134
+ function isBlankSession(hasUserInput, hasReply, unfinishedTurn = false) {
135
+ return !hasUserInput && !hasReply && !unfinishedTurn;
89
136
  }
90
137
  /** Delete one session's on-disk artifacts (log directory), best effort. */
91
138
  async function pruneSessionArtifacts(persistence, meta) {
@@ -101,8 +148,10 @@ async function pruneSessionArtifacts(persistence, meta) {
101
148
  }
102
149
  /** Upper bound on one inspection batch, so the picker never fans out unbounded. */
103
150
  const INSPECT_BATCH_SIZE = 30;
151
+ /** Sessions read before the picker's first paint, and per lazy page after it. */
152
+ export const PICKER_PAGE_SIZE = 9;
104
153
  function toResumable(item) {
105
- const { hasUserInput: _hasUserInput, hasReply: _hasReply, ...rest } = item;
154
+ const { hasUserInput: _hasUserInput, hasReply: _hasReply, hasUnfinishedTurn: _unfinished, ...rest } = item;
106
155
  return rest;
107
156
  }
108
157
  function indexFromInspected(item, stat) {
@@ -130,105 +179,184 @@ function inspectedFromIndex(entry) {
130
179
  };
131
180
  }
132
181
  /**
133
- * List resumable top-level sessions, newest first.
134
- *
135
- * Subagent-owned sessions and the current session are excluded. Sessions
136
- * whose event log cannot be inspected are kept (marked `unreadable`) instead
137
- * of silently disappearing from history; readable sessions with user input
138
- * sort first. The label is the persisted title, then the user's first input,
139
- * then the id.
140
- * @param persistence - the session persistence service.
141
- * @param currentId - the live session to exclude (empty at launch).
142
- * @returns every resumable candidate in display order (attachable live
143
- * hosts first, then readable logs, then unreadable).
144
- */
145
- export async function listResumableSessions(persistence, currentId, listHosts = listAttachableHosts) {
146
- const listing = await listResumableSessionsProgressive(persistence, currentId, { listHosts });
147
- return listing.complete;
148
- }
149
- /**
150
- * List resumable sessions, painting the recent page first.
182
+ * One pass over the store: the header sketch, the cached labels, the attachable
183
+ * hosts, and an inspection cursor that advances page by page.
151
184
  *
152
- * `onUpdate` fires after the priority page (cached + newest logs) and again
153
- * after each later inspect batch. Unchanged logs reuse `$DSH_HOME/tui-session-index.json`.
185
+ * The lazy pager, the progressive listing and the full `/resume` list all drive
186
+ * this, so the hardening lives in exactly one place: a failed or `detached`
187
+ * read is never treated as a blank session, and a blank one is only pruned
188
+ * after it was positively read.
154
189
  */
155
- export async function listResumableSessionsProgressive(persistence, currentId, options = {}) {
156
- const listHosts = options.listHosts ?? listAttachableHosts;
157
- const indexPath = options.indexPath
158
- ?? process.env.DSH_TUI_SESSION_INDEX
159
- ?? sessionIndexPath();
160
- const priorityCount = options.priorityCount ?? PICKER_PRIORITY_COUNT;
161
- const headers = await listPersistenceHeaders(persistence);
162
- const candidates = headers
163
- .filter(meta => meta.id !== currentId
164
- && meta.cwd !== undefined
165
- && meta.origin !== 'subagent'
166
- && (meta.delegationDepth ?? 0) === 0)
167
- .sort((a, b) => b.createdAt - a.createdAt);
168
- const [index, hosts] = await Promise.all([
169
- loadSessionIndex(indexPath),
170
- listHosts(),
171
- ]);
172
- const keepIds = new Set(candidates.map(meta => String(meta.id)));
173
- const indexSizeBefore = index.size;
174
- pruneSessionIndex(index, keepIds);
175
- let indexDirty = index.size !== indexSizeBefore;
176
- const sketchFromHeader = (meta) => {
177
- const cached = index.get(String(meta.id));
178
- const stat = sessionArtifactStat(persistence, meta);
190
+ class ResumableSessionSource {
191
+ persistence;
192
+ currentId;
193
+ candidates;
194
+ hosts;
195
+ index;
196
+ indexPath;
197
+ cursor = 0;
198
+ indexDirty = false;
199
+ inspected = [];
200
+ blankLiveIds = new Set();
201
+ extraLive = new Map();
202
+ constructor(persistence, currentId, candidates, hosts, index, indexPath) {
203
+ this.persistence = persistence;
204
+ this.currentId = currentId;
205
+ this.candidates = candidates;
206
+ this.hosts = hosts;
207
+ this.index = index;
208
+ this.indexPath = indexPath;
209
+ }
210
+ static async open(persistence, currentId, options = {}) {
211
+ const listHosts = options.listHosts ?? listAttachableHosts;
212
+ const indexPath = options.indexPath
213
+ ?? process.env.DSH_TUI_SESSION_INDEX
214
+ ?? sessionIndexPath();
215
+ const headers = await listPersistenceHeaders(persistence);
216
+ const candidates = headers
217
+ .filter(meta => meta.id !== currentId
218
+ && meta.cwd !== undefined
219
+ && meta.origin !== 'subagent'
220
+ && (meta.delegationDepth ?? 0) === 0)
221
+ .sort((a, b) => b.createdAt - a.createdAt);
222
+ const [index, hosts] = await Promise.all([
223
+ loadSessionIndex(indexPath),
224
+ listHosts(),
225
+ ]);
226
+ const keepIds = new Set(candidates.map(meta => String(meta.id)));
227
+ const indexSizeBefore = index.size;
228
+ pruneSessionIndex(index, keepIds);
229
+ const source = new ResumableSessionSource(persistence, currentId, candidates, hosts, index, indexPath);
230
+ source.indexDirty = index.size !== indexSizeBefore;
231
+ return source;
232
+ }
233
+ /** Header-only view: cached labels where known, the id (marked) for the rest. */
234
+ sketch() {
235
+ const sketchFromHeader = (meta) => {
236
+ const cached = this.index.get(String(meta.id));
237
+ const stat = sessionArtifactStat(this.persistence, meta);
238
+ if (stat.size > 0 && indexEntryMatchesStat(cached, stat) && cached !== undefined) {
239
+ return inspectedFromIndex(cached);
240
+ }
241
+ return {
242
+ id: meta.id,
243
+ label: cached?.label && cached.label !== '' ? cached.label : meta.id,
244
+ updatedAt: cached?.updatedAt ?? meta.createdAt,
245
+ cwd: meta.cwd ?? cached?.cwd ?? '',
246
+ hasUserInput: cached?.hasUserInput ?? true,
247
+ hasReply: cached?.hasReply ?? true,
248
+ // Nothing but the header has been read yet: the label is the id.
249
+ labelPending: true,
250
+ };
251
+ };
252
+ const sketched = this.candidates.map(meta => toResumable(sketchFromHeader(meta)));
253
+ const sketchedById = new Map(sketched.map(item => [item.id, item]));
254
+ for (const host of this.hosts) {
255
+ if (host.sessionId === this.currentId)
256
+ continue;
257
+ const attach = { pid: host.lock.pid, sock: host.sock, state: host.lock.state };
258
+ const existing = sketchedById.get(host.sessionId);
259
+ if (existing !== undefined) {
260
+ existing.attach = attach;
261
+ continue;
262
+ }
263
+ sketched.unshift({
264
+ id: host.sessionId,
265
+ label: host.sessionId,
266
+ updatedAt: Date.parse(host.lock.startedAt) || Date.now(),
267
+ cwd: '',
268
+ attach,
269
+ // A live Host whose header never made it into the candidate list has no
270
+ // inspectable label yet either.
271
+ labelPending: true,
272
+ });
273
+ }
274
+ return sketched;
275
+ }
276
+ /** Candidates this source has not inspected yet. */
277
+ get remaining() {
278
+ return this.candidates.length - this.cursor;
279
+ }
280
+ /** Take the next `count` headers, in order. They are consumed by `resolve`. */
281
+ peek(count) {
282
+ const batch = this.candidates.slice(this.cursor, this.cursor + Math.max(0, Math.floor(count)));
283
+ this.cursor += batch.length;
284
+ return batch;
285
+ }
286
+ /**
287
+ * Inspect candidates until `count` rows were added, or history runs out.
288
+ * A batch is read concurrently, but the rows keep the candidate order.
289
+ */
290
+ async take(count) {
291
+ let added = 0;
292
+ while (added < count && this.cursor < this.candidates.length) {
293
+ const size = Math.min(Math.max(1, count - added), INSPECT_BATCH_SIZE);
294
+ const batch = this.peek(size);
295
+ const settled = await Promise.all(batch.map(async (meta) => this.resolve(meta)));
296
+ for (const item of settled) {
297
+ if (item === undefined || !this.showable(item))
298
+ continue;
299
+ this.inspected.push(item);
300
+ added += 1;
301
+ }
302
+ }
303
+ }
304
+ /**
305
+ * Whether a resolved row will actually be shown. A session with a reply but
306
+ * no input of its own is not a picker row; counting it as one made a page
307
+ * report rows it never painted (and, when nothing else was left, made the
308
+ * picker believe the history was empty).
309
+ */
310
+ showable(item) {
311
+ return item.hasUserInput || item.unreadable === true;
312
+ }
313
+ /** Keep a resolved row in the display list. */
314
+ add(item) {
315
+ if (!this.showable(item))
316
+ return;
317
+ this.inspected.push(item);
318
+ }
319
+ /** Resolve one header into a row. Blank sessions are pruned and skipped. */
320
+ async resolve(meta) {
321
+ const stat = sessionArtifactStat(this.persistence, meta);
322
+ const cached = this.index.get(String(meta.id));
323
+ // Fingerprint 0/0 means locate/stat failed: never treat that as a hit.
179
324
  if (stat.size > 0 && indexEntryMatchesStat(cached, stat) && cached !== undefined) {
180
325
  return inspectedFromIndex(cached);
181
326
  }
182
- return {
183
- id: meta.id,
184
- label: cached?.label && cached.label !== '' ? cached.label : meta.id,
185
- updatedAt: cached?.updatedAt ?? meta.createdAt,
186
- cwd: meta.cwd ?? cached?.cwd ?? '',
187
- hasUserInput: cached?.hasUserInput ?? true,
188
- hasReply: cached?.hasReply ?? true,
189
- };
190
- };
191
- const sketched = candidates.map(meta => toResumable(sketchFromHeader(meta)));
192
- const sketchedById = new Map(sketched.map(item => [item.id, item]));
193
- for (const host of hosts) {
194
- if (host.sessionId === currentId)
195
- continue;
196
- const attach = { pid: host.lock.pid, sock: host.sock, state: host.lock.state };
197
- const existing = sketchedById.get(host.sessionId);
198
- if (existing !== undefined) {
199
- existing.attach = attach;
200
- continue;
327
+ const item = await this.inspectCandidate(meta);
328
+ if (item.unreadable !== true
329
+ && isBlankSession(item.hasUserInput, item.hasReply, item.hasUnfinishedTurn === true)) {
330
+ this.index.delete(String(meta.id));
331
+ this.indexDirty = true;
332
+ void pruneSessionArtifacts(this.persistence, meta);
333
+ return undefined;
334
+ }
335
+ if (stat.size > 0) {
336
+ this.index.set(String(meta.id), indexFromInspected(item, stat));
337
+ this.indexDirty = true;
201
338
  }
202
- sketched.unshift({
203
- id: host.sessionId,
204
- label: host.sessionId,
205
- updatedAt: Date.parse(host.lock.startedAt) || Date.now(),
206
- cwd: '',
207
- attach,
208
- });
339
+ return item;
209
340
  }
210
- options.onUpdate?.({ sessions: sketched, pending: true });
211
- const inspectCandidate = async (meta) => {
341
+ async inspectCandidate(meta) {
212
342
  try {
213
- const inspection = await inspectPersistenceSession(persistence, meta.id);
214
- const firstUserMessage = inspection.events.find(event => isUserMessageEvent(event));
215
- const firstUserText = firstUserMessage === undefined
216
- ? undefined
217
- : Array.from((firstUserMessage.data?.content ?? [])
218
- .map((block) => {
219
- const candidate = block;
220
- return candidate.type === 'text' && typeof candidate.text === 'string' ? candidate.text : '';
221
- })
222
- .join(' ')
223
- .replace(/\s+/gu, ' ')
224
- .trim())
225
- .slice(0, 80)
226
- .join('');
227
- const titleEvent = [...inspection.events].reverse()
228
- .find(event => event.type === 'session/title');
229
- const title = titleEvent === undefined
230
- ? undefined
231
- : titleEvent.data.title;
343
+ const inspection = await inspectPersistenceSession(this.persistence, meta.id);
344
+ // A `detached` slice (the writer is in this process and has not
345
+ // materialized the artifact yet) or an empty one is an unreadable log,
346
+ // never a blank session. Calling it blank made a listing delete a live
347
+ // session's directory; keep it visible and let resume report the truth.
348
+ if (inspection.eventState === 'detached' || inspection.events.length === 0) {
349
+ const cached = this.index.get(String(meta.id));
350
+ return {
351
+ id: meta.id,
352
+ label: cached?.label && cached.label !== '' ? cached.label : meta.id,
353
+ updatedAt: cached?.updatedAt ?? meta.createdAt,
354
+ cwd: meta.cwd ?? cached?.cwd ?? '',
355
+ hasUserInput: cached?.hasUserInput ?? false,
356
+ hasReply: cached?.hasReply ?? false,
357
+ unreadable: true,
358
+ };
359
+ }
232
360
  const last = inspection.events.at(-1);
233
361
  const updatedAt = last?.time ?? meta.createdAt;
234
362
  return {
@@ -237,15 +365,12 @@ export async function listResumableSessionsProgressive(persistence, currentId, o
237
365
  // same `session/title` value, so both surfaces name a session alike
238
366
  // and switching between them stays findable. First user input is the
239
367
  // fallback for sessions whose title has not been generated yet.
240
- label: title !== undefined && title !== ''
241
- ? title
242
- : firstUserText !== undefined && firstUserText !== ''
243
- ? firstUserText
244
- : meta.id,
368
+ label: labelFromEvents(inspection.events) ?? meta.id,
245
369
  updatedAt,
246
370
  cwd: meta.cwd ?? '',
247
- hasUserInput: firstUserMessage !== undefined,
371
+ hasUserInput: inspection.events.some(event => isUserMessageEvent(event)),
248
372
  hasReply: sessionHasReply(inspection.events),
373
+ ...(hasUnfinishedTurn(inspection.events) ? { hasUnfinishedTurn: true } : {}),
249
374
  };
250
375
  }
251
376
  catch {
@@ -262,38 +387,20 @@ export async function listResumableSessionsProgressive(persistence, currentId, o
262
387
  unreadable: true,
263
388
  };
264
389
  }
265
- };
266
- const resolveCandidate = async (meta) => {
267
- const stat = sessionArtifactStat(persistence, meta);
268
- const cached = index.get(String(meta.id));
269
- // Fingerprint 0/0 means locate/stat failed: never treat that as a hit.
270
- if (stat.size > 0 && indexEntryMatchesStat(cached, stat) && cached !== undefined) {
271
- return inspectedFromIndex(cached);
272
- }
273
- const item = await inspectCandidate(meta);
274
- if (item.unreadable !== true && isBlankSession(item.hasUserInput, item.hasReply)) {
275
- index.delete(String(meta.id));
276
- indexDirty = true;
277
- void pruneSessionArtifacts(persistence, meta);
278
- return undefined;
279
- }
280
- if (stat.size > 0) {
281
- index.set(String(meta.id), indexFromInspected(item, stat));
282
- indexDirty = true;
283
- }
284
- return item;
285
- };
286
- const inspected = [];
287
- const blankLiveIds = new Set();
288
- const extraLive = new Map();
289
- const mergeHosts = async (items) => {
290
- const resumable = items.filter(item => item.hasUserInput || item.unreadable === true);
390
+ }
391
+ /**
392
+ * The display list: the rows read so far with the attachable hosts merged in.
393
+ * Attachable hosts come first, then readable logs newest-first, then the
394
+ * unreadable ones (they may still be resumable, so they stay selectable).
395
+ */
396
+ async listing() {
397
+ const resumable = this.inspected.filter(item => this.showable(item));
291
398
  const byId = new Map(resumable.map(item => [item.id, item]));
292
- for (const host of hosts) {
293
- if (host.sessionId === currentId || blankLiveIds.has(host.sessionId))
399
+ for (const host of this.hosts) {
400
+ if (host.sessionId === this.currentId || this.blankLiveIds.has(host.sessionId))
294
401
  continue;
295
402
  const attach = { pid: host.lock.pid, sock: host.sock, state: host.lock.state };
296
- const existing = byId.get(host.sessionId) ?? extraLive.get(host.sessionId);
403
+ const existing = byId.get(host.sessionId) ?? this.extraLive.get(host.sessionId);
297
404
  if (existing !== undefined) {
298
405
  existing.attach = attach;
299
406
  if (!byId.has(host.sessionId)) {
@@ -304,73 +411,171 @@ export async function listResumableSessionsProgressive(persistence, currentId, o
304
411
  }
305
412
  // A live Host whose session never saw input nor a reply is a crashed
306
413
  // boot: stop it, remove its artifacts, and keep it out of the picker.
414
+ // Only a positively read, materialized blank log counts. A failed or
415
+ // detached read is unknown — acting on it (SIGTERM + prune) removed a
416
+ // live session's directory before.
307
417
  let blankLive = false;
308
- let liveHasUserInput = true;
418
+ let unreadableLive = false;
419
+ let liveHasUserInput = false;
420
+ let liveLabel;
309
421
  try {
310
- const inspection = await inspectPersistenceSession(persistence, host.sessionId);
311
- const hasInput = inspection.events.some(event => isUserMessageEvent(event));
312
- blankLive = isBlankSession(hasInput, sessionHasReply(inspection.events));
313
- liveHasUserInput = hasInput;
422
+ const inspection = await inspectPersistenceSession(this.persistence, host.sessionId);
423
+ const materialized = inspection.eventState !== 'detached' && inspection.events.length > 0;
424
+ if (!materialized) {
425
+ unreadableLive = true;
426
+ }
427
+ else {
428
+ const hasInput = inspection.events.some(event => isUserMessageEvent(event));
429
+ blankLive = isBlankSession(hasInput, sessionHasReply(inspection.events), hasUnfinishedTurn(inspection.events));
430
+ liveHasUserInput = hasInput;
431
+ // A Host that is running but absent from the header list still has a
432
+ // log; read its title rather than offering the user a raw uuid.
433
+ liveLabel = labelFromEvents(inspection.events);
434
+ }
314
435
  }
315
436
  catch {
316
- blankLive = true;
317
- liveHasUserInput = false;
437
+ // A read failure is not evidence of a blank boot either: keep the live
438
+ // Host attachable instead of killing it and pruning its log.
439
+ unreadableLive = true;
318
440
  }
319
441
  if (blankLive) {
320
- blankLiveIds.add(host.sessionId);
442
+ this.blankLiveIds.add(host.sessionId);
321
443
  try {
322
444
  process.kill(attach.pid, 'SIGTERM');
323
445
  }
324
446
  catch { /* already gone */ }
325
- const header = candidates.find(candidate => candidate.id === host.sessionId);
447
+ const header = this.candidates.find(candidate => candidate.id === host.sessionId);
326
448
  if (header !== undefined)
327
- void pruneSessionArtifacts(persistence, header);
449
+ void pruneSessionArtifacts(this.persistence, header);
328
450
  continue;
329
451
  }
330
452
  const injected = {
331
453
  id: host.sessionId,
332
- label: host.sessionId,
454
+ // No readable log means no title: say so instead of showing a uuid the
455
+ // user cannot recognise (and let the tail of the id still be searched).
456
+ label: liveLabel ?? (unreadableLive ? t('picker.noLogLabel', { short: host.sessionId.slice(-8) }) : host.sessionId),
333
457
  updatedAt: Date.parse(host.lock.startedAt) || Date.now(),
334
458
  cwd: '',
335
- hasUserInput: liveHasUserInput,
459
+ hasUserInput: liveHasUserInput || unreadableLive,
336
460
  hasReply: true,
461
+ ...(unreadableLive ? { unreadable: true } : {}),
337
462
  attach,
338
463
  };
339
- extraLive.set(host.sessionId, injected);
464
+ this.extraLive.set(host.sessionId, injected);
340
465
  resumable.push(injected);
341
466
  byId.set(host.sessionId, injected);
342
467
  }
343
468
  resumable.sort((a, b) => (a.attach === undefined ? 1 : 0) - (b.attach === undefined ? 1 : 0)
344
469
  || (a.unreadable === true ? 1 : 0) - (b.unreadable === true ? 1 : 0)
345
470
  || b.updatedAt - a.updatedAt);
346
- return resumable;
347
- };
348
- const emit = async (pending) => {
349
- const listed = (await mergeHosts(inspected)).map(toResumable);
350
- options.onUpdate?.({ sessions: listed, pending });
471
+ return resumable.map(toResumable);
472
+ }
473
+ async emit(pending, onUpdate) {
474
+ const listed = await this.listing();
475
+ onUpdate?.({ sessions: listed, pending });
351
476
  return listed;
477
+ }
478
+ /**
479
+ * Persist the labels read so far. Flushed after every page: a picker the user
480
+ * closed (or a `/resume` that ran while the Host booted) must not leave the
481
+ * next listing without titles again.
482
+ */
483
+ async flushIndex() {
484
+ if (!this.indexDirty)
485
+ return;
486
+ await saveSessionIndex(this.indexPath, this.index);
487
+ this.indexDirty = false;
488
+ }
489
+ }
490
+ /**
491
+ * Open a pager over the store. Nothing is inspected until the first `page()`.
492
+ */
493
+ export async function openResumableSessionPager(persistence, currentId, options = {}) {
494
+ const source = await ResumableSessionSource.open(persistence, currentId, options);
495
+ return {
496
+ page: async (size = PICKER_PAGE_SIZE) => {
497
+ // `take` counts only showable rows, so a page that comes back with no
498
+ // rows is a page the history is exhausted by — never a page whose rows
499
+ // were filtered out after being counted.
500
+ const wanted = Number.isFinite(size) && size > 0 ? Math.floor(size) : PICKER_PAGE_SIZE;
501
+ await source.take(wanted);
502
+ await source.flushIndex();
503
+ return {
504
+ sessions: await source.listing(),
505
+ remaining: source.remaining,
506
+ done: source.remaining === 0,
507
+ };
508
+ },
509
+ complete: async () => {
510
+ await source.take(Number.POSITIVE_INFINITY);
511
+ const sessions = await source.listing();
512
+ await source.flushIndex();
513
+ return sessions;
514
+ },
352
515
  };
353
- // Newest page first so the picker can paint before older logs are parsed.
354
- const priority = candidates.slice(0, Math.max(0, priorityCount));
355
- const rest = candidates.slice(priority.length);
356
- const first = await Promise.all(priority.map(async (meta) => ({ meta, item: await resolveCandidate(meta) })));
357
- for (const { item } of first) {
358
- if (item !== undefined)
359
- inspected.push(item);
516
+ }
517
+ /**
518
+ * List resumable top-level sessions, newest first.
519
+ *
520
+ * Subagent-owned sessions and the current session are excluded. Sessions
521
+ * whose event log cannot be inspected are kept (marked `unreadable`) instead
522
+ * of silently disappearing from history; readable sessions with user input
523
+ * sort first. The label is the persisted title, then the user's first input,
524
+ * then the id.
525
+ * @param persistence - the session persistence service.
526
+ * @param currentId - the live session to exclude (empty at launch).
527
+ * @returns every resumable candidate in display order (attachable live
528
+ * hosts first, then readable logs, then unreadable).
529
+ */
530
+ export async function listResumableSessions(persistence, currentId, listHosts = listAttachableHosts) {
531
+ const pager = await openResumableSessionPager(persistence, currentId, { listHosts });
532
+ return pager.complete();
533
+ }
534
+ /**
535
+ * List resumable sessions, painting the recent page first.
536
+ *
537
+ * `onUpdate` fires after the header sketch, after the first resolved title, and
538
+ * again after each later inspect batch. Unchanged logs reuse
539
+ * `$DSH_HOME/tui-session-index.json`.
540
+ */
541
+ export async function listResumableSessionsProgressive(persistence, currentId, options = {}) {
542
+ const source = await ResumableSessionSource.open(persistence, currentId, options);
543
+ options.onUpdate?.({ sessions: source.sketch(), pending: true });
544
+ const priorityCount = options.priorityCount ?? PICKER_PRIORITY_COUNT;
545
+ const priority = source.peek(priorityCount);
546
+ // Paint the first real title as soon as it exists instead of waiting for the
547
+ // whole newest page. With a cold index that is the difference between a
548
+ // loading line and a list the user can already pick from.
549
+ let firstPainted = false;
550
+ const first = await Promise.all(priority.map(async (meta) => {
551
+ const item = await source.resolve(meta);
552
+ // Only a caller that paints progressively gets the early frame.
553
+ if (!firstPainted && item !== undefined && source.remaining > 0 && options.onUpdate !== undefined) {
554
+ firstPainted = true;
555
+ source.add(item);
556
+ await source.flushIndex();
557
+ await source.emit(true, options.onUpdate);
558
+ return { item, painted: true };
559
+ }
560
+ return { item, painted: false };
561
+ }));
562
+ for (const { item, painted } of first) {
563
+ if (item !== undefined && !painted)
564
+ source.add(item);
360
565
  }
361
- await emit(rest.length > 0);
362
- for (let offset = 0; offset < rest.length; offset += INSPECT_BATCH_SIZE) {
363
- const batch = rest.slice(offset, offset + INSPECT_BATCH_SIZE);
364
- const settled = await Promise.all(batch.map(async (meta) => ({ meta, item: await resolveCandidate(meta) })));
365
- for (const { item } of settled) {
566
+ await source.flushIndex();
567
+ await source.emit(source.remaining > 0, options.onUpdate);
568
+ while (source.remaining > 0) {
569
+ const batch = source.peek(INSPECT_BATCH_SIZE);
570
+ const settled = await Promise.all(batch.map(async (meta) => source.resolve(meta)));
571
+ for (const item of settled) {
366
572
  if (item !== undefined)
367
- inspected.push(item);
573
+ source.add(item);
368
574
  }
369
- await emit(offset + INSPECT_BATCH_SIZE < rest.length);
575
+ await source.emit(source.remaining > 0, options.onUpdate);
370
576
  }
371
- const complete = (await mergeHosts(inspected)).map(toResumable);
372
- if (indexDirty)
373
- await saveSessionIndex(indexPath, index);
577
+ const complete = await source.listing();
578
+ await source.flushIndex();
374
579
  options.onUpdate?.({ sessions: complete, pending: false });
375
580
  return { sessions: complete, pending: false, complete };
376
581
  }