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 +3 -1
- package/dist/crash.js +9 -0
- package/dist/index.js +110 -10
- package/dist/session/exitNotice.js +30 -0
- package/dist/session/store.js +221 -1
- package/dist/ui/App.js +11 -6
- package/dist/ui/useAgent.js +4 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -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({
|
|
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) =>
|
|
510
|
-
|
|
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);
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -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:
|
|
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;
|