@blockrun/franklin 3.8.31 → 3.8.33
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/dist/agent/error-classifier.js +1 -1
- package/dist/agent/llm.js +83 -34
- package/dist/commands/daemon.js +0 -1
- package/dist/session/search.js +3 -3
- package/dist/session/storage.js +25 -22
- package/dist/telemetry/store.d.ts +1 -1
- package/dist/telemetry/store.js +1 -5
- package/dist/ui/app.js +24 -13
- package/dist/ui/session-picker.d.ts +11 -0
- package/dist/ui/session-picker.js +48 -17
- package/package.json +1 -1
|
@@ -68,7 +68,7 @@ export function classifyAgentError(message) {
|
|
|
68
68
|
'deadline exceeded',
|
|
69
69
|
])) {
|
|
70
70
|
return {
|
|
71
|
-
category: 'timeout', label: 'Timeout', isTransient: true,
|
|
71
|
+
category: 'timeout', label: 'Timeout', isTransient: true, maxRetries: 1,
|
|
72
72
|
suggestion: 'Check your network connection. Use /retry to try again.',
|
|
73
73
|
};
|
|
74
74
|
}
|
package/dist/agent/llm.js
CHANGED
|
@@ -5,7 +5,50 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
7
7
|
import { USER_AGENT } from '../config.js';
|
|
8
|
+
import { routeRequest, parseRoutingProfile } from '../router/index.js';
|
|
8
9
|
import { ThinkTagStripper } from './think-tag-stripper.js';
|
|
10
|
+
function getModelIdleTimeoutMs() {
|
|
11
|
+
const raw = process.env.FRANKLIN_MODEL_IDLE_TIMEOUT_MS;
|
|
12
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
13
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 25_000;
|
|
14
|
+
}
|
|
15
|
+
function linkAbortSignal(parent, child) {
|
|
16
|
+
if (!parent)
|
|
17
|
+
return () => { };
|
|
18
|
+
if (parent.aborted) {
|
|
19
|
+
child.abort(parent.reason);
|
|
20
|
+
return () => { };
|
|
21
|
+
}
|
|
22
|
+
const forward = () => child.abort(parent.reason);
|
|
23
|
+
parent.addEventListener('abort', forward, { once: true });
|
|
24
|
+
return () => parent.removeEventListener('abort', forward);
|
|
25
|
+
}
|
|
26
|
+
function createModelTimeoutError(stage, model, timeoutMs) {
|
|
27
|
+
return new Error(`Model ${stage} timed out after ${timeoutMs}ms on ${model}`);
|
|
28
|
+
}
|
|
29
|
+
async function withAbortableTimeout(work, controller, timeoutError, timeoutMs) {
|
|
30
|
+
if (timeoutMs <= 0)
|
|
31
|
+
return work();
|
|
32
|
+
let timer;
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([
|
|
35
|
+
work(),
|
|
36
|
+
new Promise((_, reject) => {
|
|
37
|
+
timer = setTimeout(() => {
|
|
38
|
+
try {
|
|
39
|
+
controller.abort(timeoutError);
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore */ }
|
|
42
|
+
reject(timeoutError);
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
}),
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (timer)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
9
52
|
/**
|
|
10
53
|
* Extract the most human-readable message from an error body.
|
|
11
54
|
* Some gateways wrap provider errors multiple times, e.g.
|
|
@@ -188,9 +231,7 @@ export class ModelClient {
|
|
|
188
231
|
resolveVirtualModel(model) {
|
|
189
232
|
if (!model.startsWith('blockrun/'))
|
|
190
233
|
return model;
|
|
191
|
-
// Import router dynamically to avoid circular deps
|
|
192
234
|
try {
|
|
193
|
-
const { routeRequest, parseRoutingProfile } = require('../router/index.js');
|
|
194
235
|
const profile = parseRoutingProfile(model);
|
|
195
236
|
if (profile) {
|
|
196
237
|
const result = routeRequest('', profile);
|
|
@@ -288,39 +329,47 @@ export class ModelClient {
|
|
|
288
329
|
if (this.debug) {
|
|
289
330
|
console.error(`[franklin] POST ${endpoint} model=${request.model}`);
|
|
290
331
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
});
|
|
297
|
-
// Handle x402 payment
|
|
298
|
-
if (response.status === 402) {
|
|
299
|
-
if (this.debug)
|
|
300
|
-
console.error('[franklin] Payment required — signing...');
|
|
301
|
-
const paymentHeader = await this.signPayment(response);
|
|
302
|
-
if (!paymentHeader) {
|
|
303
|
-
yield { kind: 'error', payload: { message: 'Payment signing failed' } };
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
response = await fetch(endpoint, {
|
|
332
|
+
const timeoutMs = getModelIdleTimeoutMs();
|
|
333
|
+
const requestController = new AbortController();
|
|
334
|
+
const unlinkAbort = linkAbortSignal(signal, requestController);
|
|
335
|
+
try {
|
|
336
|
+
let response = await withAbortableTimeout(() => fetch(endpoint, {
|
|
307
337
|
method: 'POST',
|
|
308
|
-
headers
|
|
338
|
+
headers,
|
|
309
339
|
body,
|
|
310
|
-
signal,
|
|
311
|
-
});
|
|
340
|
+
signal: requestController.signal,
|
|
341
|
+
}), requestController, createModelTimeoutError('request', request.model, timeoutMs), timeoutMs);
|
|
342
|
+
// Handle x402 payment
|
|
343
|
+
if (response.status === 402) {
|
|
344
|
+
if (this.debug)
|
|
345
|
+
console.error('[franklin] Payment required — signing...');
|
|
346
|
+
const paymentHeader = await this.signPayment(response);
|
|
347
|
+
if (!paymentHeader) {
|
|
348
|
+
yield { kind: 'error', payload: { message: 'Payment signing failed' } };
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
response = await withAbortableTimeout(() => fetch(endpoint, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers: { ...headers, ...paymentHeader },
|
|
354
|
+
body,
|
|
355
|
+
signal: requestController.signal,
|
|
356
|
+
}), requestController, createModelTimeoutError('request', request.model, timeoutMs), timeoutMs);
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
const errorBody = await response.text().catch(() => 'unknown error');
|
|
360
|
+
const message = extractApiErrorMessage(errorBody);
|
|
361
|
+
yield {
|
|
362
|
+
kind: 'error',
|
|
363
|
+
payload: { status: response.status, message },
|
|
364
|
+
};
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Parse SSE stream
|
|
368
|
+
yield* this.parseSSEStream(response, requestController, timeoutMs, request.model);
|
|
312
369
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const message = extractApiErrorMessage(errorBody);
|
|
316
|
-
yield {
|
|
317
|
-
kind: 'error',
|
|
318
|
-
payload: { status: response.status, message },
|
|
319
|
-
};
|
|
320
|
-
return;
|
|
370
|
+
finally {
|
|
371
|
+
unlinkAbort();
|
|
321
372
|
}
|
|
322
|
-
// Parse SSE stream
|
|
323
|
-
yield* this.parseSSEStream(response, signal);
|
|
324
373
|
}
|
|
325
374
|
/**
|
|
326
375
|
* Non-streaming completion for simple requests.
|
|
@@ -617,7 +666,7 @@ export class ModelClient {
|
|
|
617
666
|
return header;
|
|
618
667
|
}
|
|
619
668
|
// ─── SSE Parsing ───────────────────────────────────────────────────────
|
|
620
|
-
async *parseSSEStream(response,
|
|
669
|
+
async *parseSSEStream(response, controller, timeoutMs, model) {
|
|
621
670
|
const reader = response.body?.getReader();
|
|
622
671
|
if (!reader) {
|
|
623
672
|
yield { kind: 'error', payload: { message: 'No response body' } };
|
|
@@ -630,9 +679,9 @@ export class ModelClient {
|
|
|
630
679
|
const MAX_BUFFER = 1_000_000; // 1MB buffer cap
|
|
631
680
|
try {
|
|
632
681
|
while (true) {
|
|
633
|
-
if (signal
|
|
682
|
+
if (controller.signal.aborted)
|
|
634
683
|
break;
|
|
635
|
-
const { done, value } = await reader.read();
|
|
684
|
+
const { done, value } = await withAbortableTimeout(() => reader.read(), controller, createModelTimeoutError('stream', model, timeoutMs), timeoutMs);
|
|
636
685
|
if (done)
|
|
637
686
|
break;
|
|
638
687
|
buffer += decoder.decode(value, { stream: true });
|
package/dist/commands/daemon.js
CHANGED
|
@@ -34,7 +34,6 @@ function isRunning(pid) {
|
|
|
34
34
|
}
|
|
35
35
|
catch { /* fall through to ps */ }
|
|
36
36
|
try {
|
|
37
|
-
const { execSync } = require('node:child_process');
|
|
38
37
|
const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
39
38
|
return /franklin|runcode|node.*dist\/index/.test(cmd);
|
|
40
39
|
}
|
package/dist/session/search.js
CHANGED
|
@@ -11,9 +11,9 @@ import { listSessions, getSessionFilePath } from './storage.js';
|
|
|
11
11
|
function tokenize(text) {
|
|
12
12
|
return text
|
|
13
13
|
.toLowerCase()
|
|
14
|
-
.replace(/[^\
|
|
15
|
-
.split(/\s+/)
|
|
16
|
-
.filter(t => t.length > 1);
|
|
14
|
+
.replace(/[^\p{L}\p{N}_\s]/gu, ' ')
|
|
15
|
+
.split(/\s+/u)
|
|
16
|
+
.filter(t => t.length > 1 || /[^\x00-\x7F]/.test(t));
|
|
17
17
|
}
|
|
18
18
|
function parseQuery(query) {
|
|
19
19
|
const phrases = [];
|
package/dist/session/storage.js
CHANGED
|
@@ -158,10 +158,7 @@ export function loadSessionHistory(sessionId) {
|
|
|
158
158
|
return [];
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
|
|
162
|
-
* List all saved sessions, newest first.
|
|
163
|
-
*/
|
|
164
|
-
export function listSessions() {
|
|
161
|
+
function readSessionMetas(includeGhosts = false) {
|
|
165
162
|
const sessionsDir = getSessionsDir();
|
|
166
163
|
try {
|
|
167
164
|
const files = fs.readdirSync(sessionsDir)
|
|
@@ -174,14 +171,19 @@ export function listSessions() {
|
|
|
174
171
|
}
|
|
175
172
|
catch { /* skip corrupted */ }
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return filtered.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
174
|
+
const visible = includeGhosts ? metas : metas.filter(m => m.messageCount > 0);
|
|
175
|
+
return visible.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
180
176
|
}
|
|
181
177
|
catch {
|
|
182
178
|
return [];
|
|
183
179
|
}
|
|
184
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* List all saved sessions, newest first.
|
|
183
|
+
*/
|
|
184
|
+
export function listSessions() {
|
|
185
|
+
return readSessionMetas(false);
|
|
186
|
+
}
|
|
185
187
|
/**
|
|
186
188
|
* Find the latest saved session tagged with a given channel (e.g.
|
|
187
189
|
* `telegram:12345`). Used by non-CLI drivers to resume across process
|
|
@@ -198,25 +200,26 @@ export function findLatestSessionByChannel(channel) {
|
|
|
198
200
|
* Accepts optional activeSessionId to protect from deletion.
|
|
199
201
|
*/
|
|
200
202
|
export function pruneOldSessions(activeSessionId) {
|
|
201
|
-
const sessions =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
203
|
+
const sessions = readSessionMetas(false);
|
|
204
|
+
const allSessions = readSessionMetas(true);
|
|
205
|
+
if (sessions.length > MAX_SESSIONS) {
|
|
206
|
+
const toDelete = sessions
|
|
207
|
+
.slice(MAX_SESSIONS)
|
|
208
|
+
.filter(s => s.id !== activeSessionId); // Never delete active session
|
|
209
|
+
for (const s of toDelete) {
|
|
210
|
+
try {
|
|
211
|
+
fs.unlinkSync(sessionPath(s.id));
|
|
212
|
+
}
|
|
213
|
+
catch { /* ok */ }
|
|
214
|
+
try {
|
|
215
|
+
fs.unlinkSync(metaPath(s.id));
|
|
216
|
+
}
|
|
217
|
+
catch { /* ok */ }
|
|
214
218
|
}
|
|
215
|
-
catch { /* ok */ }
|
|
216
219
|
}
|
|
217
220
|
// Also clean up ghost sessions (0 messages, older than 5 minutes)
|
|
218
221
|
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
|
|
219
|
-
for (const s of
|
|
222
|
+
for (const s of allSessions) {
|
|
220
223
|
if (s.id === activeSessionId)
|
|
221
224
|
continue;
|
|
222
225
|
if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* files — telemetry is just a stable, aggregation-friendly view of
|
|
21
21
|
* information the user already has.
|
|
22
22
|
*/
|
|
23
|
-
import type
|
|
23
|
+
import { type SessionMeta } from '../session/storage.js';
|
|
24
24
|
interface ConsentRecord {
|
|
25
25
|
enabled: boolean;
|
|
26
26
|
enabledAt?: number;
|
package/dist/telemetry/store.js
CHANGED
|
@@ -24,6 +24,7 @@ import fs from 'node:fs';
|
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import crypto from 'node:crypto';
|
|
26
26
|
import { BLOCKRUN_DIR, VERSION } from '../config.js';
|
|
27
|
+
import { listSessions } from '../session/storage.js';
|
|
27
28
|
const CONSENT_FILE = path.join(BLOCKRUN_DIR, 'telemetry-consent.json');
|
|
28
29
|
const LOG_FILE = path.join(BLOCKRUN_DIR, 'telemetry.jsonl');
|
|
29
30
|
const INSTALL_ID_FILE = path.join(BLOCKRUN_DIR, 'telemetry-install-id.txt');
|
|
@@ -120,11 +121,6 @@ export function recordSession(meta, chain) {
|
|
|
120
121
|
export function recordLatestSessionIfEnabled(workingDir, chain) {
|
|
121
122
|
if (!isTelemetryEnabled())
|
|
122
123
|
return;
|
|
123
|
-
// Lazy import to avoid a circular session/storage <-> telemetry dependency.
|
|
124
|
-
// Using require() here keeps this module synchronous for tests.
|
|
125
|
-
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
126
|
-
const { listSessions } = require('../session/storage.js');
|
|
127
|
-
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
128
124
|
const sessions = listSessions();
|
|
129
125
|
const match = sessions.find(s => s.workDir === workingDir);
|
|
130
126
|
if (!match)
|
package/dist/ui/app.js
CHANGED
|
@@ -333,6 +333,18 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
333
333
|
const trimmed = value.trim();
|
|
334
334
|
if (!trimmed)
|
|
335
335
|
return;
|
|
336
|
+
// Exit commands bypass the busy-queue gate — if the user wants out,
|
|
337
|
+
// they want out immediately, not after whatever turn is in flight.
|
|
338
|
+
// Covers bare 'exit' / 'quit' / 'q' and slash-prefixed /exit / /quit.
|
|
339
|
+
const lower = trimmed.toLowerCase();
|
|
340
|
+
const isExit = lower === 'exit' || lower === 'quit' || lower === 'q' ||
|
|
341
|
+
lower === '/exit' || lower === '/quit';
|
|
342
|
+
if (isExit) {
|
|
343
|
+
onAbort();
|
|
344
|
+
onExit();
|
|
345
|
+
exit();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
336
348
|
// If agent is busy, queue the message — it will be auto-submitted when the turn finishes
|
|
337
349
|
if (!ready) {
|
|
338
350
|
setQueuedInputs(prev => [...prev, trimmed]);
|
|
@@ -340,13 +352,6 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
340
352
|
showStatus(`Queued message (${queuedInputsRef.current.length + 1} pending)`, 'warning', 1500);
|
|
341
353
|
return;
|
|
342
354
|
}
|
|
343
|
-
// Bare exit/quit (no slash needed)
|
|
344
|
-
const lower = trimmed.toLowerCase();
|
|
345
|
-
if (lower === 'exit' || lower === 'quit' || lower === 'q') {
|
|
346
|
-
onExit();
|
|
347
|
-
exit();
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
355
|
// ── Slash commands ──
|
|
351
356
|
if (trimmed.startsWith('/')) {
|
|
352
357
|
setInput('');
|
|
@@ -355,11 +360,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
355
360
|
const parts = trimmed.split(/\s+/);
|
|
356
361
|
const cmd = parts[0].toLowerCase();
|
|
357
362
|
switch (cmd) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
onExit();
|
|
361
|
-
exit();
|
|
362
|
-
return;
|
|
363
|
+
// /exit and /quit are handled earlier (before the busy-queue gate)
|
|
364
|
+
// so they exit the session immediately even mid-turn.
|
|
363
365
|
case '/model':
|
|
364
366
|
case '/models':
|
|
365
367
|
if (parts[1]) {
|
|
@@ -614,7 +616,16 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
614
616
|
break;
|
|
615
617
|
}
|
|
616
618
|
case 'usage': {
|
|
617
|
-
setCurrentModel(event.model)
|
|
619
|
+
// DO NOT setCurrentModel(event.model) here. currentModel
|
|
620
|
+
// represents the user's selection (e.g. 'blockrun/auto'),
|
|
621
|
+
// not what the router resolved for this specific turn. The
|
|
622
|
+
// per-turn resolved model is already captured in
|
|
623
|
+
// turnModelRef (rendered in the turn-summary line below
|
|
624
|
+
// each response) and in onModelChange('system') when the
|
|
625
|
+
// loop itself decides to swap (empty-response / 402 fallback).
|
|
626
|
+
// Overriding currentModel from every usage event made the
|
|
627
|
+
// status bar permanently show the last resolved model and
|
|
628
|
+
// create a false impression that auto mode was stuck.
|
|
618
629
|
setTurnTokens(prev => ({
|
|
619
630
|
input: prev.input + event.inputTokens,
|
|
620
631
|
output: prev.output + event.outputTokens,
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* Lists recent sessions (newest first) and returns the selected ID.
|
|
4
4
|
*/
|
|
5
5
|
import { type SessionMeta } from '../session/storage.js';
|
|
6
|
+
type SessionPickerSelection = {
|
|
7
|
+
kind: 'cancel';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'selected';
|
|
10
|
+
id: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'invalid';
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
6
15
|
/**
|
|
7
16
|
* Resolve a user-provided session identifier to a full session ID.
|
|
8
17
|
* Supports exact match and unambiguous prefix match (minimum 8 chars).
|
|
@@ -16,6 +25,7 @@ export declare function resolveSessionIdInput(input: string): {
|
|
|
16
25
|
error: 'not-found' | 'ambiguous';
|
|
17
26
|
candidates: SessionMeta[];
|
|
18
27
|
};
|
|
28
|
+
export declare function resolvePickerSelection(input: string, shown: SessionMeta[], sessions: SessionMeta[]): SessionPickerSelection;
|
|
19
29
|
/**
|
|
20
30
|
* Find the most recent session for a given working directory.
|
|
21
31
|
* Returns null if none exists.
|
|
@@ -28,3 +38,4 @@ export declare function findLatestSessionForDir(workDir: string): SessionMeta |
|
|
|
28
38
|
export declare function pickSession(opts?: {
|
|
29
39
|
workDir?: string;
|
|
30
40
|
}): Promise<string | null>;
|
|
41
|
+
export {};
|
|
@@ -46,7 +46,9 @@ function modelShort(model) {
|
|
|
46
46
|
* Returns { ok, id } on success, or { ok, error, candidates } on failure.
|
|
47
47
|
*/
|
|
48
48
|
export function resolveSessionIdInput(input) {
|
|
49
|
-
|
|
49
|
+
return resolveSessionIdFromList(input, listSessions());
|
|
50
|
+
}
|
|
51
|
+
function resolveSessionIdFromList(input, sessions) {
|
|
50
52
|
// Exact match first
|
|
51
53
|
const exact = sessions.find((s) => s.id === input);
|
|
52
54
|
if (exact)
|
|
@@ -61,6 +63,33 @@ export function resolveSessionIdInput(input) {
|
|
|
61
63
|
}
|
|
62
64
|
return { ok: false, error: 'not-found', candidates: [] };
|
|
63
65
|
}
|
|
66
|
+
export function resolvePickerSelection(input, shown, sessions) {
|
|
67
|
+
const trimmed = input.trim();
|
|
68
|
+
if (!trimmed)
|
|
69
|
+
return { kind: 'cancel' };
|
|
70
|
+
const num = parseInt(trimmed, 10);
|
|
71
|
+
if (!isNaN(num) && num >= 1 && num <= shown.length) {
|
|
72
|
+
return { kind: 'selected', id: shown[num - 1].id };
|
|
73
|
+
}
|
|
74
|
+
const exact = sessions.find((s) => s.id === trimmed);
|
|
75
|
+
if (exact)
|
|
76
|
+
return { kind: 'selected', id: exact.id };
|
|
77
|
+
const resolved = resolveSessionIdFromList(trimmed, sessions);
|
|
78
|
+
if (resolved.ok) {
|
|
79
|
+
return { kind: 'selected', id: resolved.id };
|
|
80
|
+
}
|
|
81
|
+
if (resolved.error === 'ambiguous') {
|
|
82
|
+
const preview = resolved.candidates
|
|
83
|
+
.slice(0, 3)
|
|
84
|
+
.map((candidate) => candidate.id)
|
|
85
|
+
.join(', ');
|
|
86
|
+
return {
|
|
87
|
+
kind: 'invalid',
|
|
88
|
+
message: `Ambiguous session prefix: ${trimmed}${preview ? ` (${preview}${resolved.candidates.length > 3 ? ', …' : ''})` : ''}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'invalid', message: `No session found matching: ${trimmed}` };
|
|
92
|
+
}
|
|
64
93
|
/**
|
|
65
94
|
* Find the most recent session for a given working directory.
|
|
66
95
|
* Returns null if none exists.
|
|
@@ -109,21 +138,23 @@ export async function pickSession(opts = {}) {
|
|
|
109
138
|
terminal: process.stdin.isTTY ?? false,
|
|
110
139
|
});
|
|
111
140
|
return new Promise((resolve) => {
|
|
112
|
-
|
|
113
|
-
rl.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
141
|
+
const prompt = () => {
|
|
142
|
+
rl.question(chalk.bold(' session> '), (answer) => {
|
|
143
|
+
const selection = resolvePickerSelection(answer, shown, sessions);
|
|
144
|
+
if (selection.kind === 'cancel') {
|
|
145
|
+
rl.close();
|
|
146
|
+
resolve(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (selection.kind === 'selected') {
|
|
150
|
+
rl.close();
|
|
151
|
+
resolve(selection.id);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
console.error(chalk.yellow(` ${selection.message}`));
|
|
155
|
+
prompt();
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
prompt();
|
|
128
159
|
});
|
|
129
160
|
}
|
package/package.json
CHANGED