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.
- package/README.en.md +28 -5
- package/README.md +76 -6
- package/lib/attach.js +156 -0
- package/lib/attach.js.map +1 -0
- package/lib/commands.js +73 -0
- package/lib/commands.js.map +1 -0
- package/lib/diag.js +292 -0
- package/lib/diag.js.map +1 -0
- package/lib/dialogs.js +82 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/display-sock.js +304 -86
- package/lib/display-sock.js.map +1 -1
- package/lib/dsh-compat.js +25 -4
- package/lib/dsh-compat.js.map +1 -1
- package/lib/i18n/en.js +55 -0
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +2 -0
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +55 -0
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +75 -73
- package/lib/index.js.map +1 -1
- package/lib/paint.js +20 -38
- package/lib/paint.js.map +1 -1
- package/lib/picker.js +152 -37
- package/lib/picker.js.map +1 -1
- package/lib/rows.js +120 -0
- package/lib/rows.js.map +1 -0
- package/lib/session-index.js +4 -1
- package/lib/session-index.js.map +1 -1
- package/lib/session-list.js +372 -167
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +20 -9
- package/lib/session-lock.js.map +1 -1
- package/lib/stats.js +136 -0
- package/lib/stats.js.map +1 -0
- package/lib/terminal-input.js +470 -0
- package/lib/terminal-input.js.map +1 -0
- package/lib/tui.js +257 -311
- package/lib/tui.js.map +1 -1
- package/lib/types/attach.d.ts +106 -0
- package/lib/types/commands.d.ts +103 -0
- package/lib/types/diag.d.ts +78 -0
- package/lib/types/dialogs.d.ts +79 -0
- package/lib/types/display-sock.d.ts +59 -3
- package/lib/types/dsh-compat.d.ts +7 -0
- package/lib/types/i18n/index.d.ts +4 -0
- package/lib/types/index.d.ts +8 -4
- package/lib/types/paint.d.ts +6 -3
- package/lib/types/picker.d.ts +27 -7
- package/lib/types/rows.d.ts +69 -0
- package/lib/types/session-list.d.ts +43 -3
- package/lib/types/stats.d.ts +98 -0
- package/lib/types/terminal-input.d.ts +164 -0
- package/lib/types/tui.d.ts +33 -8
- package/package.json +11 -5
package/lib/session-list.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
const inspectCandidate = async (meta) => {
|
|
341
|
+
async inspectCandidate(meta) {
|
|
212
342
|
try {
|
|
213
|
-
const inspection = await inspectPersistenceSession(persistence, meta.id);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.
|
|
223
|
-
.
|
|
224
|
-
.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
418
|
+
let unreadableLive = false;
|
|
419
|
+
let liveHasUserInput = false;
|
|
420
|
+
let liveLabel;
|
|
309
421
|
try {
|
|
310
|
-
const inspection = await inspectPersistenceSession(persistence, host.sessionId);
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
const listed =
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
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
|
-
|
|
573
|
+
source.add(item);
|
|
368
574
|
}
|
|
369
|
-
await emit(
|
|
575
|
+
await source.emit(source.remaining > 0, options.onUpdate);
|
|
370
576
|
}
|
|
371
|
-
const complete =
|
|
372
|
-
|
|
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
|
}
|