kritya 0.8.32-beta → 0.8.34-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,9 @@ kritya # enter
124
124
  kritya [directory] [options]
125
125
 
126
126
  -c, --continue resume the most recent session for this directory
127
- -r, --resume pick a past session from a list
127
+ -r, --resume [name] list past sessions, or resume one by its auto-derived
128
+ name (e.g. `kritya -r fix-login-bug`) or 5-character
129
+ code; plain `-r` opens the list
128
130
  -m, --model <id> use any model ID your provider offers
129
131
  -p, --provider <name> nvidia (default), openai, openrouter, groq, deepseek,
130
132
  mistral, together, ollama, switchyard (multi-model
package/dist/crash.js CHANGED
@@ -95,6 +95,15 @@ export function installCrashHandlers(opts) {
95
95
  process.on("uncaughtException", (err) => onCrash(err, "uncaught exception"));
96
96
  process.on("unhandledRejection", (reason) => onCrash(reason, "unhandled rejection"));
97
97
  }
98
+ /**
99
+ * True while the crash path is running. The crash report already tells the user
100
+ * where the transcript is and how to resume, so anything that would print its
101
+ * own sign-off on the way out (the interactive exit notice) checks this first
102
+ * and stays quiet rather than saying it twice, in two different formats.
103
+ */
104
+ export function isHandlingCrash() {
105
+ return handling;
106
+ }
98
107
  /** Test seam: forget that handlers were installed. */
99
108
  export function resetCrashHandlersForTest() {
100
109
  installed = false;
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { ProviderClient } from "./provider/client.js";
12
12
  import { createSwitchyardClient } from "./provider/switchyardClient.js";
13
13
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, } from "./provider/switchyardSidecar.js";
14
14
  import { SessionStore } from "./session/store.js";
15
+ import { exitNotice } from "./session/exitNotice.js";
15
16
  import { AuditLog } from "./audit/audit.js";
16
17
  import { runAuditCli } from "./audit/cli.js";
17
18
  import { runSkillsCli } from "./agent/skillsCli.js";
@@ -39,7 +40,7 @@ import { isAiDisclosureShown, markAiDisclosureShown } from "./trust/aiDisclosure
39
40
  import { isBannerSeen, markBannerSeen } from "./trust/bannerSeen.js";
40
41
  import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
41
42
  import { runHeadless } from "./headless.js";
42
- import { installCrashHandlers } from "./crash.js";
43
+ import { installCrashHandlers, isHandlingCrash } from "./crash.js";
43
44
  import { createSubagentRunner } from "./agent/subagents.js";
44
45
  import { VERSION } from "./version.js";
45
46
  import { updateNoticeForUser } from "./update/check.js";
@@ -49,7 +50,9 @@ Usage: kritya [directory] [options]
49
50
 
50
51
  Options:
51
52
  -c, --continue resume the most recent session for this directory
52
- -r, --resume pick a past session for this directory from a list
53
+ -r, --resume [name] list past sessions, or resume one by its auto-derived
54
+ name (e.g. kritya -r fix-login-bug) or 5-character code;
55
+ plain -r opens the picker
53
56
  -m, --model <id> model ID to use (any model your provider offers)
54
57
  -p, --provider <n> provider: nvidia (default), openai, openrouter, groq,
55
58
  deepseek, mistral, together, ollama, or a custom one
@@ -88,11 +91,34 @@ Setup:
88
91
  3. cd your-project && kritya .
89
92
 
90
93
  Config file: ~/.kritya/config.json { "apiKey", "model", "customModels": [{"id"}] }`;
94
+ /** True if `p` names an existing directory — used to keep `-r <dir>` unambiguous. */
95
+ function isDirectory(p) {
96
+ try {
97
+ return fs.statSync(p).isDirectory();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Write the exit notice to stdout. Best-effort: this runs from an "exit"
105
+ * handler, where the stream may already be gone, and there is nowhere left to
106
+ * report that if it is.
107
+ */
108
+ function writeExitNotice(text) {
109
+ try {
110
+ process.stdout.write(text + "\n");
111
+ }
112
+ catch {
113
+ // Nothing left to write to.
114
+ }
115
+ }
91
116
  function parseArgs(argv) {
92
117
  const args = {
93
118
  dir: ".",
94
119
  continue: false,
95
120
  resume: false,
121
+ resumeName: "",
96
122
  model: "",
97
123
  provider: "",
98
124
  help: false,
@@ -108,8 +134,23 @@ function parseArgs(argv) {
108
134
  const a = argv[i];
109
135
  if (a === "-c" || a === "--continue")
110
136
  args.continue = true;
111
- else if (a === "-r" || a === "--resume")
137
+ else if (a === "-r" || a === "--resume") {
138
+ args.resume = true;
139
+ // `-r <name>` resumes one session directly; a bare `--resume` opens the
140
+ // picker. The value is only consumed when it cannot be the directory
141
+ // positional — `kritya -r some-dir` is the documented way to open a
142
+ // project, so an existing directory always wins. When the name really is
143
+ // an existing directory, `--resume=<name>` still forces it through.
144
+ const next = argv[i + 1];
145
+ if (next && !next.startsWith("-") && !isDirectory(next)) {
146
+ args.resumeName = next;
147
+ i++;
148
+ }
149
+ }
150
+ else if (a.startsWith("--resume=")) {
112
151
  args.resume = true;
152
+ args.resumeName = a.slice("--resume=".length);
153
+ }
113
154
  else if (a === "-m" || a === "--model")
114
155
  args.model = argv[++i] ?? "";
115
156
  else if (a === "-p" || a === "--provider")
@@ -390,10 +431,60 @@ async function main() {
390
431
  const sessionMeter = privacyMode
391
432
  ? createMeter(session.id, "off")
392
433
  : createMeter(session.id, config.otel);
393
- const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
394
- const initialTasks = !privacyMode && args.continue ? SessionStore.loadLatestTasks(workspace) : [];
434
+ // `-r <name>` is resolved before the session file is opened, so a typo fails
435
+ // fast with a clear message instead of silently starting an empty session.
436
+ // Precedence is named resume > -c > a fresh session.
437
+ let resumedFile;
438
+ if (!privacyMode && args.resumeName) {
439
+ const found = SessionStore.resolveSession(workspace, args.resumeName);
440
+ if ("error" in found) {
441
+ console.error(found.error);
442
+ process.exit(1);
443
+ }
444
+ resumedFile = found.file;
445
+ }
446
+ let initialHistory = [];
447
+ let initialTasks = [];
448
+ if (!privacyMode) {
449
+ if (resumedFile) {
450
+ initialHistory = SessionStore.loadFile(resumedFile);
451
+ initialTasks = SessionStore.loadTasksForSession(resumedFile);
452
+ }
453
+ else if (args.continue) {
454
+ initialHistory = SessionStore.loadLatest(workspace) ?? [];
455
+ initialTasks = SessionStore.loadLatestTasks(workspace);
456
+ }
457
+ }
395
458
  session.start(initialHistory);
396
- const resumeSessions = !privacyMode && args.resume ? SessionStore.listSessions(workspace) : [];
459
+ // The picker only opens when no session was named — `-r a3f9k2` has already
460
+ // decided which conversation to open.
461
+ const resumeSessions = !privacyMode && args.resume && !resumedFile ? SessionStore.listSessions(workspace) : [];
462
+ // Where the conversation went, said once on the way out. This is the last
463
+ // thing on screen and the only place the session's short name is discoverable
464
+ // without re-running --resume, so it is worth printing rather than leaving
465
+ // the user to guess. Registered on "exit" rather than drawn as a transcript
466
+ // item so it covers every way out — Ctrl+C and a real signal included, not
467
+ // just /exit. The crash handler prints its own version of this, so it is
468
+ // skipped there rather than said twice in two formats.
469
+ let farewellPrinted = false;
470
+ const printFarewell = () => {
471
+ if (farewellPrinted || isHandlingCrash())
472
+ return;
473
+ farewellPrinted = true;
474
+ const file = session.path;
475
+ const text = exitNotice({
476
+ privacyMode,
477
+ messageCount: session.messageCount,
478
+ // A count with no file on disk means persistence failed somewhere along
479
+ // the way — don't promise a resume that cannot work.
480
+ file: file && fs.existsSync(file) ? file : undefined,
481
+ workspace,
482
+ cwd: process.cwd(),
483
+ isTTY: Boolean(process.stdout.isTTY),
484
+ });
485
+ if (text)
486
+ writeExitNotice(text);
487
+ };
397
488
  // Filled in once the app is mounted, below; the crash handler needs a way to
398
489
  // tear the UI down and is installed before there is a UI to tear down.
399
490
  const ui = {};
@@ -409,6 +500,10 @@ async function main() {
409
500
  // resort fallback path stays on the synchronous, fire-and-forget flush()
410
501
  // inside cleanup() — it's best-effort only, not guaranteed to land.
411
502
  process.on("exit", cleanup);
503
+ // Registered after cleanup so the sign-off really is the last thing printed:
504
+ // cleanup kills background servers and MCP children, and anything they write
505
+ // to stderr on the way down would otherwise land after it.
506
+ process.on("exit", printFarewell);
412
507
  // Same reasoning as the signal handlers below, for the other way the process
413
508
  // can die without firing "exit": an error nothing caught. Also hands the
414
509
  // terminal back — Ink leaves it in raw mode with the cursor hidden.
@@ -418,9 +513,14 @@ async function main() {
418
513
  unmountUi: () => ui.instance?.unmount(),
419
514
  details: () => {
420
515
  const file = session.path;
421
- return file
422
- ? ["", `The conversation was saved to ${file}`, `Resume it with: kritya -c ${workspace}`]
423
- : [];
516
+ if (!file)
517
+ return [];
518
+ const where = workspace === process.cwd() ? "" : ` ${workspace}`;
519
+ return [
520
+ "",
521
+ `The conversation was saved to ${file}`,
522
+ `Resume it with: kritya -r ${SessionStore.displayName(file)}${where}`,
523
+ ];
424
524
  },
425
525
  });
426
526
  // Default signal handling terminates WITHOUT firing "exit", which would
@@ -608,7 +708,7 @@ async function main() {
608
708
  // `renderTime` excludes Ink's own throttling, so it is the raw cost of the
609
709
  // frame rather than the interval between frames.
610
710
  const renderHistogram = sessionMeter.histogram("kritya.render.duration_ms", RENDER_BOUNDS_MS);
611
- ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
711
+ ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, resumedName: resumedFile ? SessionStore.displayName(resumedFile) : undefined, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
612
712
  client = newClient;
613
713
  }, onRequestPermissionReady: (fn) => {
614
714
  permissionRef.current = fn;
@@ -0,0 +1,30 @@
1
+ import { SessionStore } from "./store.js";
2
+ /**
3
+ * The sign-off printed when an interactive session ends, or null when there is
4
+ * nothing worth saying.
5
+ *
6
+ * This is the only place the session's short name is discoverable without
7
+ * re-running `--resume`, so it is the whole point of the feature: quit, and the
8
+ * terminal tells you the exact command that brings this conversation back.
9
+ *
10
+ * Pulled out of the CLI entry point as a pure function on purpose. It is the
11
+ * one piece of the exit path with real logic — privacy mode, an empty session,
12
+ * singular/plural, and whether the resume command even works from here — and
13
+ * `index.tsx` is excluded from coverage, so anything left in there is logic
14
+ * nothing measures.
15
+ */
16
+ export function exitNotice(input) {
17
+ if (input.privacyMode)
18
+ return "Session not saved (--privacy).";
19
+ if (!input.file || input.messageCount <= 0)
20
+ return null;
21
+ const dim = input.isTTY ? "\x1b[2m" : "";
22
+ const plain = input.isTTY ? "\x1b[22m" : "";
23
+ const n = input.messageCount;
24
+ // `kritya -r <code>` only resolves from the directory the session belongs to,
25
+ // so say where that was when it is not where the user is standing.
26
+ const where = input.workspace === input.cwd ? "" : ` ${input.workspace}`;
27
+ return (`${dim}Session saved · ${n} message${n === 1 ? "" : "s"}${plain}\n` +
28
+ `${dim} resume ${plain}kritya -r ${SessionStore.displayName(input.file)}${where}\n` +
29
+ `${dim} list ${plain}kritya -r`);
30
+ }
@@ -58,6 +58,77 @@ function sessionDir(workspace) {
58
58
  const hash = crypto.createHash("sha1").update(workspace).digest("hex").slice(0, 12);
59
59
  return path.join(CONFIG_DIR, "sessions", hash);
60
60
  }
61
+ /**
62
+ * Short, typeable handle for a session — a 5-character base36 code derived
63
+ * deterministically from the transcript's filename.
64
+ *
65
+ * The full id (the ISO timestamp basename) stays the on-disk name and the key
66
+ * that correlates the audit log and telemetry spans, and nothing here changes
67
+ * that. This is only what the user reads and retypes: `2026-10-06T19-08-39-123Z`
68
+ * is not a thing anyone types twice, which is why the exit notice and the
69
+ * `--resume` picker both show this instead.
70
+ *
71
+ * Derived rather than stored on purpose — there is no index file to drift out
72
+ * of sync with the directory, and the code is stable for the life of the
73
+ * transcript. Uniqueness is therefore *checked* at lookup time rather than
74
+ * assumed: see resolveSession(), which reports an ambiguous prefix instead of
75
+ * silently opening the wrong conversation.
76
+ */
77
+ export function shortSessionId(file) {
78
+ const name = path.basename(file, ".jsonl");
79
+ const digest = crypto.createHash("sha1").update(name).digest();
80
+ // Low-order base36 digits, so the code is uniformly distributed over the
81
+ // alphabet rather than clustered by the timestamp's shared prefix.
82
+ return digest.readUInt32BE(0).toString(36).padStart(5, "0").slice(-5);
83
+ }
84
+ /**
85
+ * Turn a first user message into a short, typeable session name:
86
+ * "Fix the login bug!!" -> "fix-the-login-bug". Lowercase, runs of
87
+ * non-alphanumerics become one hyphen, capped at 40 characters so the name
88
+ * stays something a person will actually retype. Returns "" when nothing
89
+ * usable remains (empty input, only punctuation).
90
+ */
91
+ export function slugifySessionName(text) {
92
+ const slug = text
93
+ .slice(0, 80)
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, "-")
96
+ .replace(/^-+|-+$/g, "")
97
+ .slice(0, 40)
98
+ .replace(/-+$/g, "");
99
+ return slug;
100
+ }
101
+ /**
102
+ * Pull plain text out of a message for naming/preview purposes. Content can
103
+ * be a string, an array of content parts (text + images), or null — images
104
+ * contribute nothing to a name.
105
+ */
106
+ function messageText(message) {
107
+ const content = message.content;
108
+ if (typeof content === "string")
109
+ return content;
110
+ if (Array.isArray(content)) {
111
+ return content
112
+ .filter((p) => p.type === "text")
113
+ .map((p) => p.text)
114
+ .join(" ");
115
+ }
116
+ return "";
117
+ }
118
+ /**
119
+ * The first message that may name a session: a real user message, not one of
120
+ * the synthetic "[...]" notes the agent writes to itself (undo, summaries).
121
+ * Mirrors the filter listSessions() uses for its preview, so the name and
122
+ * the preview always agree about what the conversation is about.
123
+ */
124
+ function nameableText(message) {
125
+ if (message.role !== "user")
126
+ return undefined;
127
+ const text = messageText(message).replace(/\s+/g, " ").trim();
128
+ if (!text || text.startsWith("["))
129
+ return undefined;
130
+ return text;
131
+ }
61
132
  /**
62
133
  * Transcripts can contain secrets that passed through tool output, so they are
63
134
  * always written 0o600 rather than inheriting whatever mode the file had.
@@ -76,12 +147,56 @@ export class SessionStore {
76
147
  ephemeral;
77
148
  dir;
78
149
  file;
150
+ /**
151
+ * Messages persisted to the current file so far. Tracked in memory rather
152
+ * than re-read from disk because the exit notice needs it after the process
153
+ * has already begun tearing down, where reading back a possibly 50 MB
154
+ * transcript would be both slow and pointless.
155
+ */
156
+ count = 0;
79
157
  /** When ephemeral, nothing is persisted to disk (used by subagents). */
80
158
  constructor(workspace, ephemeral = false) {
81
159
  this.ephemeral = ephemeral;
82
160
  this.dir = sessionDir(workspace);
83
161
  this.file = this.newFilePath();
84
162
  }
163
+ /** How many messages this session has written. 0 means nothing is on disk yet. */
164
+ get messageCount() {
165
+ return this.count;
166
+ }
167
+ /**
168
+ * Whether this file's name is settled — either a sidecar already exists
169
+ * (written earlier, or by a previous process) or we just wrote one.
170
+ * Reset by rotate(), which starts a new file that deserves its own name.
171
+ */
172
+ nameResolved = false;
173
+ /**
174
+ * Name this session from its first real user message, once. Later messages
175
+ * never rename it: the name is meant to stay stable so `-r <name>` keeps
176
+ * working and the resume list doesn't shift under the user.
177
+ */
178
+ maybeNameSession(message) {
179
+ if (this.nameResolved || this.ephemeral)
180
+ return;
181
+ if (SessionStore.readName(this.file)) {
182
+ this.nameResolved = true;
183
+ return;
184
+ }
185
+ const text = nameableText(message);
186
+ if (!text)
187
+ return;
188
+ const slug = slugifySessionName(text);
189
+ if (!slug)
190
+ return;
191
+ try {
192
+ writeSessionFile(SessionStore.nameFilePathFor(this.file), slug + "\n");
193
+ this.nameResolved = true;
194
+ }
195
+ catch (err) {
196
+ // Naming is cosmetic; never fail a turn over it.
197
+ debugLog(`SessionStore.maybeNameSession(${this.file})`, err);
198
+ }
199
+ }
85
200
  newFilePath() {
86
201
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
87
202
  return path.join(this.dir, `${stamp}.jsonl`);
@@ -109,6 +224,35 @@ export class SessionStore {
109
224
  static tasksFilePathFor(sessionFile) {
110
225
  return sessionFile.replace(/\.jsonl$/, ".tasks.json");
111
226
  }
227
+ /**
228
+ * Sidecar holding the session's human-readable name (see
229
+ * slugifySessionName), written once from the first real user message.
230
+ * A sidecar rather than a rename: the transcript filename is the session's
231
+ * stable id for audit/telemetry correlation, and renaming it mid-session
232
+ * would churn that id plus the tasks sidecar that keys off it.
233
+ */
234
+ static nameFilePathFor(sessionFile) {
235
+ return sessionFile.replace(/\.jsonl$/, ".name");
236
+ }
237
+ /** The auto-derived name for a session file, if it earned one yet. */
238
+ static readName(sessionFile) {
239
+ try {
240
+ const name = fs.readFileSync(SessionStore.nameFilePathFor(sessionFile), "utf8").trim();
241
+ return name || undefined;
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ }
247
+ /**
248
+ * What the user types and sees: the auto-derived name when the session has
249
+ * one, otherwise the short hash code. Every surface that names a session —
250
+ * the exit notice, the crash report, the `--resume` picker, `-r <name>` —
251
+ * goes through here so they can never disagree.
252
+ */
253
+ static displayName(sessionFile) {
254
+ return SessionStore.readName(sessionFile) ?? shortSessionId(sessionFile);
255
+ }
112
256
  /**
113
257
  * Persists the current task checklist alongside the session, so `-c`/`-r`
114
258
  * can restore not just the conversation but what was done vs. pending.
@@ -157,6 +301,7 @@ export class SessionStore {
157
301
  if (seed.length) {
158
302
  writeSessionFile(this.file, seed.map((m) => JSON.stringify(capMessageContent(m)) + "\n").join(""));
159
303
  }
304
+ this.count = seed.length;
160
305
  }
161
306
  /**
162
307
  * Appends one JSON-encoded message per call — a crash mid-write can only
@@ -175,6 +320,8 @@ export class SessionStore {
175
320
  fs.appendFileSync(this.file, JSON.stringify(capMessageContent(message)) + "\n", {
176
321
  mode: 0o600,
177
322
  });
323
+ this.count++;
324
+ this.maybeNameSession(message);
178
325
  }
179
326
  catch (err) {
180
327
  // Persistence is best-effort; never crash the session over it.
@@ -184,6 +331,9 @@ export class SessionStore {
184
331
  /** Start over with a fresh session file (used by /clear). */
185
332
  rotate() {
186
333
  this.file = this.newFilePath();
334
+ this.count = 0;
335
+ // The new file deserves its own name, from its own first message.
336
+ this.nameResolved = false;
187
337
  }
188
338
  /**
189
339
  * Rewrite the session file to hold exactly `messages`, atomically. The log
@@ -198,6 +348,7 @@ export class SessionStore {
198
348
  fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
199
349
  hardenWindowsDir(CONFIG_DIR);
200
350
  writeSessionFile(this.file, messages.map((m) => JSON.stringify(capMessageContent(m)) + "\n").join(""));
351
+ this.count = messages.length;
201
352
  }
202
353
  catch (err) {
203
354
  // Persistence is best-effort; never crash the session over it.
@@ -252,6 +403,66 @@ export class SessionStore {
252
403
  return false;
253
404
  }
254
405
  }
406
+ /**
407
+ * Turn a user-typed session handle into a transcript file.
408
+ *
409
+ * Accepts the short code shown by the exit notice and the `--resume` picker,
410
+ * or the full timestamp basename, or any unambiguous prefix of either — the
411
+ * whole point is that the user can retype something short. Deliberately
412
+ * searches the entire directory rather than listSessions()' display window of
413
+ * 20, so a code copied from an earlier exit notice still resolves.
414
+ *
415
+ * Returns an error string instead of throwing so the CLI can print it and
416
+ * exit(1) without the caller having to distinguish "no such session" from
417
+ * "ambiguous" itself.
418
+ */
419
+ static resolveSession(workspace, name) {
420
+ const query = name.trim().toLowerCase();
421
+ if (!query)
422
+ return { error: "No session name given." };
423
+ const dir = sessionDir(workspace);
424
+ let entries;
425
+ try {
426
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
427
+ }
428
+ catch {
429
+ return { error: "No saved sessions for this directory yet." };
430
+ }
431
+ if (!entries.length)
432
+ return { error: "No saved sessions for this directory yet." };
433
+ // Keyed by file, not appended to a list: a query can prefix both a
434
+ // transcript's short code *and* its timestamp basename (every basename
435
+ // starts with the year), and counting that file twice would report a
436
+ // perfectly unambiguous name as ambiguous.
437
+ const matches = new Map();
438
+ for (const entry of entries) {
439
+ const file = path.join(dir, entry);
440
+ // Same containment check the IPC path uses — a symlink dropped into the
441
+ // session directory must not turn `--resume <name>` into an arbitrary
442
+ // file read.
443
+ if (!SessionStore.isSessionFile(workspace, file))
444
+ continue;
445
+ const base = path.basename(entry, ".jsonl").toLowerCase();
446
+ const short = shortSessionId(file);
447
+ const slug = SessionStore.readName(file)?.toLowerCase();
448
+ if (short.startsWith(query) ||
449
+ base.startsWith(query) ||
450
+ (slug !== undefined && (slug === query || slug.startsWith(query))))
451
+ matches.set(file, short);
452
+ }
453
+ if (!matches.size) {
454
+ return {
455
+ error: `No session matching "${name}" in this directory. Run \`kritya -r\` to list them.`,
456
+ };
457
+ }
458
+ if (matches.size > 1) {
459
+ const codes = [...matches.values()].join(", ");
460
+ return {
461
+ error: `"${name}" matches ${matches.size} sessions (${codes}) — use more characters.`,
462
+ };
463
+ }
464
+ return { file: [...matches.keys()][0] };
465
+ }
255
466
  static loadFile(filePath) {
256
467
  const messages = [];
257
468
  let raw;
@@ -337,6 +548,7 @@ export class SessionStore {
337
548
  if (fs.statSync(file).mtimeMs < cutoff) {
338
549
  fs.unlinkSync(file);
339
550
  fs.rmSync(SessionStore.tasksFilePathFor(file), { force: true });
551
+ fs.rmSync(SessionStore.nameFilePathFor(file), { force: true });
340
552
  }
341
553
  }
342
554
  catch (err) {
@@ -404,7 +616,15 @@ export class SessionStore {
404
616
  catch {
405
617
  // leave empty
406
618
  }
407
- sessions.push({ file, date, preview, title, count: lines.length });
619
+ sessions.push({
620
+ file,
621
+ date,
622
+ preview,
623
+ title,
624
+ name: SessionStore.readName(file) ?? "",
625
+ shortId: shortSessionId(file),
626
+ count: lines.length,
627
+ });
408
628
  }
409
629
  return sessions;
410
630
  }
package/dist/ui/App.js CHANGED
@@ -30,7 +30,7 @@ const MENTION_RE = /(^|\s)@([^\s@]*)$/;
30
30
  const MENTION_ALL_RE = /(?:^|\s)@([^\s@]+)/g;
31
31
  const MAX_MENTION_CHARS = 8000;
32
32
  const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
33
- export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
33
+ export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, resumedName, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
34
34
  const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
35
35
  /**
36
36
  * Leave, but not until the screen has caught up.
@@ -119,6 +119,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
119
119
  config,
120
120
  uiBridge,
121
121
  resumedCount,
122
+ resumedName,
122
123
  initialTasks,
123
124
  resumeSessions,
124
125
  refreshFileList,
@@ -505,13 +506,17 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
505
506
  }, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask, in every mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "confirmBypassMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Switch to auto mode? (sandbox-gated)" }), _jsx(Text, { children: "EVERY tool call will auto-approve, including destructive shell commands (rm -rf, force-push, etc.) \u2014 nothing will ask first." }), _jsx(Text, { dimColor: true, children: "The sandbox \u2014 not a human \u2014 is what contains any damage this mode causes." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again goes back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "red", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "model" && (_jsx(ModelPicker, { current: model, customModels: config.customModels ?? [], onSelect: (id) => {
506
507
  setPhase("input");
507
508
  setModelEverywhere(id);
508
- }, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
509
- .filter((s) => s.title.toLowerCase().includes(resumeFilter.toLowerCase()) ||
510
- SessionStore.matchesContent(s.file, resumeFilter))
509
+ }, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session", " ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one \u00B7 -r <name> to skip this)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
510
+ .filter((s) => {
511
+ const f = resumeFilter.toLowerCase();
512
+ return (s.title.toLowerCase().includes(f) ||
513
+ s.name.toLowerCase().includes(f) ||
514
+ SessionStore.matchesContent(s.file, resumeFilter));
515
+ })
511
516
  .map((s) => ({
512
- label: s.title,
517
+ label: s.name || s.title,
513
518
  value: s.file,
514
- hint: `${s.date} · ${s.count} msgs`,
519
+ hint: `${s.shortId} · ${s.date} · ${s.count} msgs`,
515
520
  })), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "input" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: (v) => {
516
521
  setInput(v.replace(/\t/g, ""));
517
522
  setCmdIndex(0);
@@ -45,7 +45,7 @@ function initialToolStatus(name) {
45
45
  * permission state, usage/cost tracking, and session resume. Input handling
46
46
  * (text box, slash-command suggestions, @mention autocomplete) stays in App.
47
47
  */
48
- export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
48
+ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, resumedName, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
49
49
  const nextId = useRef(0);
50
50
  const [items, setItems] = useState(() => {
51
51
  const initial = [
@@ -64,7 +64,9 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
64
64
  initial.push({
65
65
  id: nextId.current++,
66
66
  kind: "info",
67
- text: `Resumed previous session (${resumedCount} messages)${taskNote}.`,
67
+ text: resumedName
68
+ ? `Resumed session ${resumedName} (${resumedCount} messages)${taskNote}.`
69
+ : `Resumed previous session (${resumedCount} messages)${taskNote}.`,
68
70
  });
69
71
  }
70
72
  return initial;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.32-beta",
3
+ "version": "0.8.34-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {