kritya 0.8.2-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/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-aware symbol extraction for the repo map. Deliberately regex/
|
|
3
|
+
* heuristic-based rather than AST- or LSP-based: it must run over a whole
|
|
4
|
+
* repository in one pass, on any machine, with no language server to spawn and
|
|
5
|
+
* no native parser dependency. The result is a ctags-style skeleton — top-level
|
|
6
|
+
* (and lightly-nested) definitions with their signatures — not a precise parse.
|
|
7
|
+
* It is intended as a cheap map to orient the model, so the tradeoff favors
|
|
8
|
+
* breadth and speed over completeness: an occasional missed or spurious symbol
|
|
9
|
+
* is acceptable, a hung or crashed scan is not.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* File extension → language family. Covers the mainstream languages; a file
|
|
13
|
+
* whose extension isn't here is simply skipped by the map (graceful: no crash,
|
|
14
|
+
* no noise), rather than run through a generic pattern that produces junk.
|
|
15
|
+
*/
|
|
16
|
+
const EXT_FAMILY = {
|
|
17
|
+
ts: "js",
|
|
18
|
+
tsx: "js",
|
|
19
|
+
mts: "js",
|
|
20
|
+
cts: "js",
|
|
21
|
+
js: "js",
|
|
22
|
+
jsx: "js",
|
|
23
|
+
mjs: "js",
|
|
24
|
+
cjs: "js",
|
|
25
|
+
py: "python",
|
|
26
|
+
pyi: "python",
|
|
27
|
+
go: "go",
|
|
28
|
+
rs: "rust",
|
|
29
|
+
rb: "ruby",
|
|
30
|
+
php: "php",
|
|
31
|
+
java: "clike",
|
|
32
|
+
kt: "clike",
|
|
33
|
+
kts: "clike",
|
|
34
|
+
cs: "clike",
|
|
35
|
+
scala: "clike",
|
|
36
|
+
swift: "clike",
|
|
37
|
+
c: "clike",
|
|
38
|
+
h: "clike",
|
|
39
|
+
cpp: "clike",
|
|
40
|
+
cc: "clike",
|
|
41
|
+
cxx: "clike",
|
|
42
|
+
hpp: "clike",
|
|
43
|
+
hh: "clike",
|
|
44
|
+
hxx: "clike",
|
|
45
|
+
m: "clike",
|
|
46
|
+
mm: "clike",
|
|
47
|
+
};
|
|
48
|
+
/** Source extensions the map will scan (the keys of EXT_FAMILY). */
|
|
49
|
+
export const CODE_EXTENSIONS = new Set(Object.keys(EXT_FAMILY));
|
|
50
|
+
export function familyForExt(ext) {
|
|
51
|
+
return EXT_FAMILY[ext] ?? null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Control-flow keywords that can precede `(...)` and otherwise look like a
|
|
55
|
+
* method definition (`if (x) {`, `for (…) {`). Used to reject false positives
|
|
56
|
+
* in the brace-language method heuristic.
|
|
57
|
+
*/
|
|
58
|
+
const CONTROL_KEYWORDS = new Set([
|
|
59
|
+
"if",
|
|
60
|
+
"for",
|
|
61
|
+
"while",
|
|
62
|
+
"switch",
|
|
63
|
+
"catch",
|
|
64
|
+
"return",
|
|
65
|
+
"do",
|
|
66
|
+
"else",
|
|
67
|
+
"case",
|
|
68
|
+
"with",
|
|
69
|
+
"await",
|
|
70
|
+
"when",
|
|
71
|
+
"elif",
|
|
72
|
+
"except",
|
|
73
|
+
"finally",
|
|
74
|
+
"match",
|
|
75
|
+
"using",
|
|
76
|
+
"lock",
|
|
77
|
+
"synchronized",
|
|
78
|
+
]);
|
|
79
|
+
/** Keyword-led definition patterns, matched against the trimmed line. */
|
|
80
|
+
const PATTERNS = {
|
|
81
|
+
js: [
|
|
82
|
+
/^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?class\s+[\w$]/,
|
|
83
|
+
/^(?:export\s+)?(?:declare\s+)?interface\s+[\w$]/,
|
|
84
|
+
/^(?:export\s+)?(?:declare\s+)?enum\s+[\w$]/,
|
|
85
|
+
/^(?:export\s+)?(?:declare\s+)?type\s+[\w$][^=]*=/,
|
|
86
|
+
/^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?function\s*\*?\s*[\w$]/,
|
|
87
|
+
// arrow-function assigned to a binding: `export const foo = (…) => …`
|
|
88
|
+
/^(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+[\w$]+\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>/,
|
|
89
|
+
],
|
|
90
|
+
python: [/^(?:async\s+)?def\s+[\w]/, /^class\s+[\w]/],
|
|
91
|
+
go: [/^func\s+(?:\([^)]*\)\s*)?[\w]/, /^type\s+[\w]/],
|
|
92
|
+
rust: [
|
|
93
|
+
/^(?:pub(?:\([^)]*\))?\s+)?(?:default\s+)?(?:async\s+)?(?:unsafe\s+)?(?:const\s+)?fn\s+[\w]/,
|
|
94
|
+
/^(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|union|mod|type)\s+[\w]/,
|
|
95
|
+
/^impl(?:\s|<)/,
|
|
96
|
+
/^macro_rules!\s+[\w]/,
|
|
97
|
+
],
|
|
98
|
+
ruby: [/^(?:def|class|module)\s+[\w]/],
|
|
99
|
+
php: [
|
|
100
|
+
/^(?:abstract\s+|final\s+)?(?:public\s+|private\s+|protected\s+|static\s+)*function\s+[\w]/,
|
|
101
|
+
/^(?:abstract\s+|final\s+)?(?:class|interface|trait|enum)\s+[\w]/,
|
|
102
|
+
],
|
|
103
|
+
clike: [
|
|
104
|
+
/^(?:(?:public|private|protected|internal|static|final|sealed|abstract|export|open|data)\s+)*(?:class|struct|enum|interface|namespace|record|union|protocol|object|module)\s+[\w]/,
|
|
105
|
+
// Kotlin `fun` / Swift `func`
|
|
106
|
+
/^(?:(?:public|private|protected|internal|static|final|open|override|suspend|operator|inline|external)\s+)*(?:fun|func)\s+[\w]/,
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
/** Families whose class methods are detected by the brace heuristic below. */
|
|
110
|
+
const BRACE_METHOD_FAMILIES = new Set(["js", "clike"]);
|
|
111
|
+
/**
|
|
112
|
+
* Heuristic for a class/struct method written on a single line that opens its
|
|
113
|
+
* body with `{` — e.g. `async runTurn(text): Promise<void> {` or
|
|
114
|
+
* `public void foo(int x) {`. Requires the brace so a bare call (`foo();`) or a
|
|
115
|
+
* multi-line signature isn't mistaken for a definition, and rejects
|
|
116
|
+
* control-flow keywords (`if (x) {`).
|
|
117
|
+
*/
|
|
118
|
+
function isBraceMethod(trimmed) {
|
|
119
|
+
if (!trimmed.endsWith("{"))
|
|
120
|
+
return false;
|
|
121
|
+
if (trimmed.startsWith("}") || trimmed.startsWith("@"))
|
|
122
|
+
return false;
|
|
123
|
+
// A closing paren, an optional return type, then the opening brace.
|
|
124
|
+
if (!/\)\s*(?::\s*[^{]+|->\s*[^{]+)?\{$/.test(trimmed))
|
|
125
|
+
return false;
|
|
126
|
+
// The identifier immediately before the parameter list is the method name.
|
|
127
|
+
const m = trimmed.match(/([\w$~]+)\s*(?:<[^>]*>)?\s*\(/);
|
|
128
|
+
if (!m)
|
|
129
|
+
return false;
|
|
130
|
+
if (CONTROL_KEYWORDS.has(m[1]))
|
|
131
|
+
return false;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
/** Strip the trailing body-opener (`{`, `{}`) or colon, collapse whitespace, and cap length. */
|
|
135
|
+
function cleanSignature(trimmed) {
|
|
136
|
+
let s = trimmed
|
|
137
|
+
.replace(/\s*\{\s*\}?\s*$/, "")
|
|
138
|
+
.replace(/\s*[:;]\s*$/, "")
|
|
139
|
+
.replace(/\s+/g, " ")
|
|
140
|
+
.trim();
|
|
141
|
+
if (s.length > 120)
|
|
142
|
+
s = s.slice(0, 119) + "…";
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
function leadingIndent(line) {
|
|
146
|
+
let indent = 0;
|
|
147
|
+
for (const ch of line) {
|
|
148
|
+
if (ch === " ")
|
|
149
|
+
indent++;
|
|
150
|
+
else if (ch === "\t")
|
|
151
|
+
indent += 2;
|
|
152
|
+
else
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return indent;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extract up to `maxSymbols` definition signatures from a file's contents.
|
|
159
|
+
* Returns an empty array for files with no recognizable definitions (or an
|
|
160
|
+
* unsupported extension), which the caller drops from the map entirely.
|
|
161
|
+
*/
|
|
162
|
+
export function extractSymbols(content, ext, maxSymbols) {
|
|
163
|
+
const family = familyForExt(ext);
|
|
164
|
+
if (!family)
|
|
165
|
+
return [];
|
|
166
|
+
const patterns = PATTERNS[family];
|
|
167
|
+
const braceMethods = BRACE_METHOD_FAMILIES.has(family);
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const raw of content.split("\n")) {
|
|
170
|
+
if (out.length >= maxSymbols)
|
|
171
|
+
break;
|
|
172
|
+
const trimmed = raw.trim();
|
|
173
|
+
if (!trimmed)
|
|
174
|
+
continue;
|
|
175
|
+
// Skip obvious comment lines (also guards `*` block-comment continuations).
|
|
176
|
+
if (trimmed.startsWith("//") ||
|
|
177
|
+
trimmed.startsWith("*") ||
|
|
178
|
+
trimmed.startsWith("/*") ||
|
|
179
|
+
trimmed.startsWith("#")) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let matched = patterns.some((re) => re.test(trimmed));
|
|
183
|
+
if (!matched && braceMethods)
|
|
184
|
+
matched = isBraceMethod(trimmed);
|
|
185
|
+
if (!matched)
|
|
186
|
+
continue;
|
|
187
|
+
out.push({ indent: leadingIndent(raw), text: cleanSignature(trimmed) });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
5
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
6
|
+
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
7
|
+
import { debugLog } from "../config/debug.js";
|
|
8
|
+
function sessionDir(workspace) {
|
|
9
|
+
const hash = crypto.createHash("sha1").update(workspace).digest("hex").slice(0, 12);
|
|
10
|
+
return path.join(CONFIG_DIR, "sessions", hash);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Transcripts can contain secrets that passed through tool output, so they are
|
|
14
|
+
* always written 0o600 rather than inheriting whatever mode the file had.
|
|
15
|
+
* writeFileAtomicSync is the shared implementation (see src/atomicWrite.ts):
|
|
16
|
+
* a sibling temp file renamed over the target, so a crash mid-write can never
|
|
17
|
+
* leave a half-written transcript behind.
|
|
18
|
+
*/
|
|
19
|
+
function writeSessionFile(filePath, data) {
|
|
20
|
+
writeFileAtomicSync(filePath, data, { mode: 0o600 });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Persists the conversation as one JSON message per line. A session maps to
|
|
24
|
+
* one file; --continue reloads the most recent file for the workspace.
|
|
25
|
+
*/
|
|
26
|
+
export class SessionStore {
|
|
27
|
+
ephemeral;
|
|
28
|
+
dir;
|
|
29
|
+
file;
|
|
30
|
+
/** When ephemeral, nothing is persisted to disk (used by subagents). */
|
|
31
|
+
constructor(workspace, ephemeral = false) {
|
|
32
|
+
this.ephemeral = ephemeral;
|
|
33
|
+
this.dir = sessionDir(workspace);
|
|
34
|
+
this.file = this.newFilePath();
|
|
35
|
+
}
|
|
36
|
+
newFilePath() {
|
|
37
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
38
|
+
return path.join(this.dir, `${stamp}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Stable identifier for this session (the transcript file's basename), used
|
|
42
|
+
* to correlate the audit log and telemetry spans with the session. Updates
|
|
43
|
+
* when the session rotates via reset(), so all three stay in lockstep.
|
|
44
|
+
*/
|
|
45
|
+
get id() {
|
|
46
|
+
return path.basename(this.file, ".jsonl");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Absolute path of this session's transcript, or undefined when ephemeral
|
|
50
|
+
* (nothing is on disk). Used by the crash handler to tell the user where the
|
|
51
|
+
* conversation survives — a crash is exactly when that matters.
|
|
52
|
+
*/
|
|
53
|
+
get path() {
|
|
54
|
+
return this.ephemeral ? undefined : this.file;
|
|
55
|
+
}
|
|
56
|
+
/** Path of the sidecar file that holds this session's task checklist. */
|
|
57
|
+
tasksFilePath() {
|
|
58
|
+
return SessionStore.tasksFilePathFor(this.file);
|
|
59
|
+
}
|
|
60
|
+
static tasksFilePathFor(sessionFile) {
|
|
61
|
+
return sessionFile.replace(/\.jsonl$/, ".tasks.json");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Persists the current task checklist alongside the session, so `-c`/`-r`
|
|
65
|
+
* can restore not just the conversation but what was done vs. pending.
|
|
66
|
+
* Best-effort, same as append() — never let this crash a turn.
|
|
67
|
+
*/
|
|
68
|
+
saveTasks(tasks) {
|
|
69
|
+
if (this.ephemeral)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
74
|
+
if (!tasks.length) {
|
|
75
|
+
fs.rmSync(this.tasksFilePath(), { force: true });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
writeSessionFile(this.tasksFilePath(), JSON.stringify(tasks));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
debugLog(`SessionStore.saveTasks(${this.tasksFilePath()})`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Loads the task checklist saved alongside a given session file, if any. */
|
|
85
|
+
static loadTasksForSession(sessionFile) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(SessionStore.tasksFilePathFor(sessionFile), "utf8");
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
if (!Array.isArray(parsed))
|
|
90
|
+
return [];
|
|
91
|
+
return parsed.filter((t) => !!t &&
|
|
92
|
+
typeof t === "object" &&
|
|
93
|
+
typeof t.text === "string" &&
|
|
94
|
+
["pending", "in_progress", "done"].includes(t.status));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Begin a session, optionally seeded with resumed history. */
|
|
101
|
+
start(seed = []) {
|
|
102
|
+
if (this.ephemeral)
|
|
103
|
+
return;
|
|
104
|
+
// Session transcripts can contain secrets that passed through tool
|
|
105
|
+
// output — keep them readable only by the owner.
|
|
106
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
108
|
+
if (seed.length) {
|
|
109
|
+
writeSessionFile(this.file, seed.map((m) => JSON.stringify(m) + "\n").join(""));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Appends one JSON-encoded message per call — a crash mid-write can only
|
|
114
|
+
* ever corrupt the single line currently being written, never anything
|
|
115
|
+
* appended before it (each prior line was already a completed, separate
|
|
116
|
+
* write). loadFile/matchesContent/listSessions all parse line-by-line and
|
|
117
|
+
* skip a line that fails JSON.parse, so a truncated last line loses at
|
|
118
|
+
* most that one message rather than the whole session.
|
|
119
|
+
*/
|
|
120
|
+
append(message) {
|
|
121
|
+
if (this.ephemeral)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
126
|
+
fs.appendFileSync(this.file, JSON.stringify(message) + "\n", { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Persistence is best-effort; never crash the session over it.
|
|
130
|
+
debugLog(`SessionStore.append(${this.file})`, err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Start over with a fresh session file (used by /clear). */
|
|
134
|
+
rotate() {
|
|
135
|
+
this.file = this.newFilePath();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Rewrite the session file to hold exactly `messages`, atomically. The log
|
|
139
|
+
* is otherwise append-only; this is the one place it's rewound — used by
|
|
140
|
+
* /rewind to drop the messages after a checkpoint. The atomic write means a
|
|
141
|
+
* reader (e.g. a concurrent --continue) never sees a half-written file.
|
|
142
|
+
*/
|
|
143
|
+
overwrite(messages) {
|
|
144
|
+
if (this.ephemeral)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
148
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
149
|
+
writeSessionFile(this.file, messages.map((m) => JSON.stringify(m) + "\n").join(""));
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Persistence is best-effort; never crash the session over it.
|
|
153
|
+
debugLog(`SessionStore.overwrite(${this.file})`, err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* True if `filePath` is a real session transcript belonging to `workspace`
|
|
158
|
+
* — i.e. resolves inside that workspace's session directory. Callers that
|
|
159
|
+
* accept a session path from outside the process (e.g. the Electron
|
|
160
|
+
* renderer over IPC) must check this before loading it: without it,
|
|
161
|
+
* "load this session" is really "read any file the OS user can read".
|
|
162
|
+
*/
|
|
163
|
+
static isSessionFile(workspace, filePath) {
|
|
164
|
+
const dir = sessionDir(workspace);
|
|
165
|
+
const resolved = path.resolve(dir, filePath);
|
|
166
|
+
const relative = path.relative(dir, resolved);
|
|
167
|
+
return (resolved.endsWith(".jsonl") &&
|
|
168
|
+
relative !== "" &&
|
|
169
|
+
!relative.startsWith("..") &&
|
|
170
|
+
!path.isAbsolute(relative));
|
|
171
|
+
}
|
|
172
|
+
static loadFile(filePath) {
|
|
173
|
+
const messages = [];
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return messages;
|
|
180
|
+
}
|
|
181
|
+
for (const line of raw.split("\n")) {
|
|
182
|
+
if (!line.trim())
|
|
183
|
+
continue;
|
|
184
|
+
try {
|
|
185
|
+
messages.push(JSON.parse(line));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Skip corrupt lines rather than failing the resume.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
193
|
+
/** Lines of a session file, one JSON message per non-blank line (unparsed). */
|
|
194
|
+
static readLines(filePath) {
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
return raw.split("\n").filter((line) => line.trim());
|
|
203
|
+
}
|
|
204
|
+
/** True if any message's content in the session file contains query (case-insensitive). Used for --resume search beyond the title preview. */
|
|
205
|
+
static matchesContent(filePath, query) {
|
|
206
|
+
if (!query.trim())
|
|
207
|
+
return true;
|
|
208
|
+
const needle = query.toLowerCase();
|
|
209
|
+
for (const line of SessionStore.readLines(filePath)) {
|
|
210
|
+
let message;
|
|
211
|
+
try {
|
|
212
|
+
message = JSON.parse(line);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (typeof message.content === "string" && message.content.toLowerCase().includes(needle)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Delete session files older than `retentionDays` across all workspaces.
|
|
225
|
+
* Best-effort. 0 or negative means "keep forever" — auto-delete is
|
|
226
|
+
* disabled rather than treated as an immediate-expiry retention window.
|
|
227
|
+
*/
|
|
228
|
+
static cleanupOldSessions(retentionDays) {
|
|
229
|
+
if (retentionDays <= 0)
|
|
230
|
+
return;
|
|
231
|
+
const root = path.join(CONFIG_DIR, "sessions");
|
|
232
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
233
|
+
let dirs;
|
|
234
|
+
try {
|
|
235
|
+
dirs = fs.readdirSync(root);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const d of dirs) {
|
|
241
|
+
const dir = path.join(root, d);
|
|
242
|
+
let files;
|
|
243
|
+
try {
|
|
244
|
+
files = fs.readdirSync(dir);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
if (!f.endsWith(".jsonl"))
|
|
251
|
+
continue;
|
|
252
|
+
const file = path.join(dir, f);
|
|
253
|
+
try {
|
|
254
|
+
if (fs.statSync(file).mtimeMs < cutoff) {
|
|
255
|
+
fs.unlinkSync(file);
|
|
256
|
+
fs.rmSync(SessionStore.tasksFilePathFor(file), { force: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
debugLog(`SessionStore.cleanupOldSessions(${file})`, err);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
static loadLatest(workspace) {
|
|
266
|
+
const latest = SessionStore.listSessions(workspace)[0];
|
|
267
|
+
if (!latest)
|
|
268
|
+
return null;
|
|
269
|
+
const messages = SessionStore.loadFile(latest.file);
|
|
270
|
+
return messages.length ? messages : null;
|
|
271
|
+
}
|
|
272
|
+
/** The task checklist saved alongside the most recent session for `workspace`, if any. */
|
|
273
|
+
static loadLatestTasks(workspace) {
|
|
274
|
+
const latest = SessionStore.listSessions(workspace)[0];
|
|
275
|
+
return latest ? SessionStore.loadTasksForSession(latest.file) : [];
|
|
276
|
+
}
|
|
277
|
+
/** Sessions for a workspace, newest first, with a preview of the first user message. */
|
|
278
|
+
static listSessions(workspace) {
|
|
279
|
+
const dir = sessionDir(workspace);
|
|
280
|
+
let files;
|
|
281
|
+
try {
|
|
282
|
+
files = fs
|
|
283
|
+
.readdirSync(dir)
|
|
284
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
285
|
+
.sort()
|
|
286
|
+
.reverse();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
const sessions = [];
|
|
292
|
+
for (const f of files.slice(0, 20)) {
|
|
293
|
+
const file = path.join(dir, f);
|
|
294
|
+
const lines = SessionStore.readLines(file);
|
|
295
|
+
if (!lines.length)
|
|
296
|
+
continue;
|
|
297
|
+
let cleaned = "";
|
|
298
|
+
for (const line of lines) {
|
|
299
|
+
let message;
|
|
300
|
+
try {
|
|
301
|
+
message = JSON.parse(line);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
continue; // skip corrupt lines rather than failing the preview
|
|
305
|
+
}
|
|
306
|
+
if (message.role === "user" &&
|
|
307
|
+
typeof message.content === "string" &&
|
|
308
|
+
message.content.trim() &&
|
|
309
|
+
!message.content.startsWith("[") // skip synthetic notes (undo, summaries)
|
|
310
|
+
) {
|
|
311
|
+
cleaned = message.content.replace(/\s+/g, " ").trim();
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const preview = cleaned ? cleaned.slice(0, 60) : "(no preview)";
|
|
316
|
+
const title = cleaned ? cleaned.slice(0, 48) : "(untitled session)";
|
|
317
|
+
let date = "";
|
|
318
|
+
try {
|
|
319
|
+
date = fs.statSync(file).mtime.toLocaleString();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// leave empty
|
|
323
|
+
}
|
|
324
|
+
sessions.push({ file, date, preview, title, count: lines.length });
|
|
325
|
+
}
|
|
326
|
+
return sessions;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { scrubbedShellEnv } from "../config/config.js";
|
|
4
|
+
import { buildSandboxedCommand, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "./sandbox.js";
|
|
5
|
+
const MAX_BUFFER_CHARS = 50_000;
|
|
6
|
+
/**
|
|
7
|
+
* Registry of long-running processes the agent started with shell
|
|
8
|
+
* background:true (dev servers, watchers). Captures a rolling tail of
|
|
9
|
+
* interleaved stdout/stderr; everything is killed when kritya exits.
|
|
10
|
+
*/
|
|
11
|
+
class BackgroundManager {
|
|
12
|
+
procs = new Map();
|
|
13
|
+
counter = 0;
|
|
14
|
+
start(command, cwd, sandboxMode) {
|
|
15
|
+
const id = `bg_${++this.counter}`;
|
|
16
|
+
const isWindows = os.platform() === "win32";
|
|
17
|
+
// detached on POSIX puts the command in its own process group, so kill()
|
|
18
|
+
// can signal the whole tree (shell + whatever it spawned), not just it.
|
|
19
|
+
// scrubbedShellEnv: background commands must not inherit provider API keys.
|
|
20
|
+
const spawnOpts = {
|
|
21
|
+
cwd,
|
|
22
|
+
env: scrubbedShellEnv(),
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
detached: !isWindows,
|
|
25
|
+
};
|
|
26
|
+
let proc;
|
|
27
|
+
let cleanup;
|
|
28
|
+
if (shouldSandbox(sandboxMode, command)) {
|
|
29
|
+
const wrapped = buildSandboxedCommand(command, cwd);
|
|
30
|
+
if (wrapped) {
|
|
31
|
+
const opts = wrapped.env
|
|
32
|
+
? { ...spawnOpts, env: { ...spawnOpts.env, ...wrapped.env } }
|
|
33
|
+
: spawnOpts;
|
|
34
|
+
proc = spawn(wrapped.cmd, wrapped.args, opts);
|
|
35
|
+
cleanup = wrapped.cleanup;
|
|
36
|
+
}
|
|
37
|
+
else if (requiresSandbox(sandboxMode)) {
|
|
38
|
+
// Same fail-closed contract as the foreground shell tool: "strict"
|
|
39
|
+
// means the sandbox is a hard requirement, so refuse rather than
|
|
40
|
+
// start an unconfined background process.
|
|
41
|
+
throw new Error(`sandboxExec is "strict" but sandboxing is unavailable here (${sandboxUnavailableReason()}) ` +
|
|
42
|
+
`— refusing to start an unsandboxed background process`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// Sandboxing was requested but no sandbox binary is available here —
|
|
46
|
+
// fall back to a plain run, same as the foreground shell tool does.
|
|
47
|
+
proc = spawn(command, { ...spawnOpts, shell: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// shell:true lets Node pick the right shell and quoting per OS from a
|
|
52
|
+
// single command string, same as the exec() used by the regular shell
|
|
53
|
+
// tool -- manually building a cmd.exe "/c" args array (the previous
|
|
54
|
+
// approach) mis-parses commands that themselves contain quotes.
|
|
55
|
+
proc = spawn(command, { ...spawnOpts, shell: true });
|
|
56
|
+
}
|
|
57
|
+
const entry = { proc, command, buffer: "", exitCode: null, running: true };
|
|
58
|
+
const append = (chunk) => {
|
|
59
|
+
entry.buffer += chunk.toString();
|
|
60
|
+
if (entry.buffer.length > MAX_BUFFER_CHARS) {
|
|
61
|
+
entry.buffer = entry.buffer.slice(-MAX_BUFFER_CHARS);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
proc.stdout?.on("data", append);
|
|
65
|
+
proc.stderr?.on("data", append);
|
|
66
|
+
// "exit" (not "close"): grandchildren may inherit the pipes and keep them
|
|
67
|
+
// open after the direct child dies; exit still fires then.
|
|
68
|
+
proc.on("exit", (code) => {
|
|
69
|
+
entry.running = false;
|
|
70
|
+
entry.exitCode = code;
|
|
71
|
+
proc.stdout?.destroy();
|
|
72
|
+
proc.stderr?.destroy();
|
|
73
|
+
cleanup?.();
|
|
74
|
+
});
|
|
75
|
+
proc.on("error", (err) => {
|
|
76
|
+
entry.running = false;
|
|
77
|
+
entry.buffer += `\n[failed to start: ${err.message}]`;
|
|
78
|
+
// If the sandbox binary itself fails to spawn, "exit" may never fire, so
|
|
79
|
+
// the macOS temp profile would leak. cleanup is fs.rm({force:true}),
|
|
80
|
+
// which is idempotent, so running it from both handlers is safe.
|
|
81
|
+
cleanup?.();
|
|
82
|
+
});
|
|
83
|
+
this.procs.set(id, entry);
|
|
84
|
+
return { id };
|
|
85
|
+
}
|
|
86
|
+
signal(entry, sig) {
|
|
87
|
+
try {
|
|
88
|
+
if (os.platform() !== "win32" && entry.proc.pid) {
|
|
89
|
+
process.kill(-entry.proc.pid, sig); // whole process group
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
entry.proc.kill(sig);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
read(id) {
|
|
101
|
+
const entry = this.procs.get(id);
|
|
102
|
+
if (!entry)
|
|
103
|
+
return null;
|
|
104
|
+
return {
|
|
105
|
+
output: entry.buffer,
|
|
106
|
+
running: entry.running,
|
|
107
|
+
exitCode: entry.exitCode,
|
|
108
|
+
command: entry.command,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
kill(id) {
|
|
112
|
+
const entry = this.procs.get(id);
|
|
113
|
+
if (!entry || !entry.running)
|
|
114
|
+
return false;
|
|
115
|
+
return this.signal(entry, "SIGTERM");
|
|
116
|
+
}
|
|
117
|
+
list() {
|
|
118
|
+
return [...this.procs.entries()].map(([id, e]) => ({
|
|
119
|
+
id,
|
|
120
|
+
command: e.command,
|
|
121
|
+
running: e.running,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
killAll() {
|
|
125
|
+
for (const entry of this.procs.values()) {
|
|
126
|
+
if (entry.running)
|
|
127
|
+
this.signal(entry, "SIGKILL");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const backgroundManager = new BackgroundManager();
|