ladder-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/LICENSE +27 -0
- package/README.md +85 -0
- package/dist/desktop-work.js +102 -0
- package/dist/environment.js +193 -0
- package/dist/index.js +289 -0
- package/dist/kimi-api.js +72 -0
- package/dist/kimi-mcp-config.js +85 -0
- package/dist/kimi-runner.js +142 -0
- package/dist/session-store.js +122 -0
- package/dist/task-store.js +164 -0
- package/dist/transports/acp.js +415 -0
- package/dist/transports/cli-admin.js +239 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { resolveKimiPaths } from './environment.js';
|
|
4
|
+
function normalizeForCompare(candidate) {
|
|
5
|
+
return path.resolve(candidate).replace(/\\/g, '/').toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function statTime(candidate) {
|
|
8
|
+
if (!candidate)
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return fs.statSync(candidate).mtime.toISOString();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function readSessionTitle(sessionDir) {
|
|
18
|
+
if (!sessionDir)
|
|
19
|
+
return undefined;
|
|
20
|
+
const statePath = path.join(sessionDir, 'state.json');
|
|
21
|
+
try {
|
|
22
|
+
const data = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
23
|
+
for (const key of ['title', 'name', 'summary']) {
|
|
24
|
+
const value = data[key];
|
|
25
|
+
if (typeof value === 'string' && value.trim())
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Optional metadata.
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function toSession(record) {
|
|
35
|
+
const id = record.sessionId ?? record.id;
|
|
36
|
+
const workDir = record.workDir;
|
|
37
|
+
if (!id || !workDir)
|
|
38
|
+
return undefined;
|
|
39
|
+
const lastModified = record.updatedAt ?? record.createdAt ?? statTime(record.sessionDir) ?? new Date(0).toISOString();
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
title: record.title?.trim() || readSessionTitle(record.sessionDir) || '(untitled)',
|
|
43
|
+
workDir,
|
|
44
|
+
sessionDir: record.sessionDir,
|
|
45
|
+
lastModified,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readIndexSessions(kimiDir) {
|
|
49
|
+
const indexPath = path.join(kimiDir, 'session_index.jsonl');
|
|
50
|
+
let raw;
|
|
51
|
+
try {
|
|
52
|
+
raw = fs.readFileSync(indexPath, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const sessions = [];
|
|
58
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
59
|
+
if (!line.trim())
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const session = toSession(JSON.parse(line));
|
|
63
|
+
if (session)
|
|
64
|
+
sessions.push(session);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Skip corrupt lines without failing the whole listing.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return sessions;
|
|
71
|
+
}
|
|
72
|
+
function readDirectorySessions(kimiDir) {
|
|
73
|
+
const sessionsRoot = path.join(kimiDir, 'sessions');
|
|
74
|
+
let workDirs;
|
|
75
|
+
try {
|
|
76
|
+
workDirs = fs.readdirSync(sessionsRoot);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const sessions = [];
|
|
82
|
+
for (const workDirName of workDirs) {
|
|
83
|
+
if (!workDirName.startsWith('wd_'))
|
|
84
|
+
continue;
|
|
85
|
+
const workDirPath = path.join(sessionsRoot, workDirName);
|
|
86
|
+
let sessionDirs;
|
|
87
|
+
try {
|
|
88
|
+
sessionDirs = fs.readdirSync(workDirPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const sessionDirName of sessionDirs) {
|
|
94
|
+
if (!sessionDirName.startsWith('session_'))
|
|
95
|
+
continue;
|
|
96
|
+
const sessionDir = path.join(workDirPath, sessionDirName);
|
|
97
|
+
sessions.push({
|
|
98
|
+
id: sessionDirName,
|
|
99
|
+
title: readSessionTitle(sessionDir) || '(untitled)',
|
|
100
|
+
workDir: `(unknown: ${workDirName})`,
|
|
101
|
+
sessionDir,
|
|
102
|
+
lastModified: statTime(sessionDir) ?? new Date(0).toISOString(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return sessions;
|
|
107
|
+
}
|
|
108
|
+
export function listSessions(options = {}) {
|
|
109
|
+
const limit = options.limit ?? 20;
|
|
110
|
+
const kimiDir = options.kimiDir ?? resolveKimiPaths().kimiDir;
|
|
111
|
+
const byId = new Map();
|
|
112
|
+
for (const session of [...readDirectorySessions(kimiDir), ...readIndexSessions(kimiDir)]) {
|
|
113
|
+
if (options.workDir && normalizeForCompare(session.workDir) !== normalizeForCompare(options.workDir))
|
|
114
|
+
continue;
|
|
115
|
+
const existing = byId.get(session.id);
|
|
116
|
+
if (!existing || existing.workDir.startsWith('(unknown:'))
|
|
117
|
+
byId.set(session.id, session);
|
|
118
|
+
}
|
|
119
|
+
return [...byId.values()]
|
|
120
|
+
.sort((a, b) => b.lastModified.localeCompare(a.lastModified))
|
|
121
|
+
.slice(0, limit);
|
|
122
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const MAX_TASK_OUTPUT_CHARS = 100_000;
|
|
2
|
+
const MAX_TASK_OUTPUT_CHUNKS = 1_000;
|
|
3
|
+
const MAX_TASKS = 100;
|
|
4
|
+
const CANCEL_HOOK_TIMEOUT_MS = 5_000;
|
|
5
|
+
export class TaskStore {
|
|
6
|
+
nextId = 1;
|
|
7
|
+
tasks = new Map();
|
|
8
|
+
create(kind, executor, cancel) {
|
|
9
|
+
const now = new Date().toISOString();
|
|
10
|
+
const id = `task_${this.nextId++}`;
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const task = {
|
|
13
|
+
id,
|
|
14
|
+
kind,
|
|
15
|
+
status: 'pending',
|
|
16
|
+
createdAt: now,
|
|
17
|
+
updatedAt: now,
|
|
18
|
+
output: '',
|
|
19
|
+
outputChunks: [],
|
|
20
|
+
controller,
|
|
21
|
+
cancel,
|
|
22
|
+
};
|
|
23
|
+
this.tasks.set(id, task);
|
|
24
|
+
this.enforceRetention();
|
|
25
|
+
queueMicrotask(async () => {
|
|
26
|
+
if (controller.signal.aborted)
|
|
27
|
+
return;
|
|
28
|
+
task.status = 'running';
|
|
29
|
+
task.startedAt = new Date().toISOString();
|
|
30
|
+
task.updatedAt = task.startedAt;
|
|
31
|
+
try {
|
|
32
|
+
const result = await executor(controller.signal, (text) => this.append(id, text));
|
|
33
|
+
if (controller.signal.aborted)
|
|
34
|
+
return;
|
|
35
|
+
if (result.output)
|
|
36
|
+
this.append(id, result.output);
|
|
37
|
+
if (result.metadata)
|
|
38
|
+
task.metadata = { ...(task.metadata ?? {}), ...result.metadata };
|
|
39
|
+
task.status = 'succeeded';
|
|
40
|
+
task.finishedAt = new Date().toISOString();
|
|
41
|
+
task.updatedAt = task.finishedAt;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (controller.signal.aborted)
|
|
45
|
+
return;
|
|
46
|
+
task.status = 'failed';
|
|
47
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
48
|
+
task.finishedAt = new Date().toISOString();
|
|
49
|
+
task.updatedAt = task.finishedAt;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return this.snapshot(task);
|
|
53
|
+
}
|
|
54
|
+
get(id) {
|
|
55
|
+
const task = this.tasks.get(id);
|
|
56
|
+
return task ? this.snapshot(task) : undefined;
|
|
57
|
+
}
|
|
58
|
+
list() {
|
|
59
|
+
return [...this.tasks.values()]
|
|
60
|
+
.map((task) => this.snapshot(task))
|
|
61
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
62
|
+
}
|
|
63
|
+
async cancel(id) {
|
|
64
|
+
const task = this.tasks.get(id);
|
|
65
|
+
if (!task)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (['succeeded', 'failed', 'cancelled'].includes(task.status))
|
|
68
|
+
return this.snapshot(task);
|
|
69
|
+
// Mark terminal up-front (synchronously, before the first await) so a concurrent
|
|
70
|
+
// cancel(id) sees `cancelled` and returns via the guard above instead of running
|
|
71
|
+
// the (possibly non-idempotent) cancel hook a second time on the same child.
|
|
72
|
+
task.status = 'cancelled';
|
|
73
|
+
task.finishedAt = new Date().toISOString();
|
|
74
|
+
task.updatedAt = task.finishedAt;
|
|
75
|
+
task.controller.abort();
|
|
76
|
+
if (task.cancel) {
|
|
77
|
+
try {
|
|
78
|
+
// Bound the cancel hook: a hook that never resolves must not block kimi_task_cancel
|
|
79
|
+
// forever. The task is already marked cancelled, so on timeout we record a note and
|
|
80
|
+
// return; the hook keeps running detached but no longer holds up the caller.
|
|
81
|
+
await this.withTimeout(Promise.resolve(task.cancel()), CANCEL_HOOK_TIMEOUT_MS);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const cancelError = error instanceof Error ? error.message : String(error);
|
|
85
|
+
task.error = task.error ? `${task.error}; cancel hook also failed: ${cancelError}` : cancelError;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return this.snapshot(task);
|
|
89
|
+
}
|
|
90
|
+
append(id, text) {
|
|
91
|
+
const task = this.tasks.get(id);
|
|
92
|
+
if (!task || typeof text !== 'string' || text.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
task.outputChunks.push(text);
|
|
95
|
+
task.output = task.output ? `${task.output}\n${text}` : text;
|
|
96
|
+
this.trimOutput(task);
|
|
97
|
+
task.updatedAt = new Date().toISOString();
|
|
98
|
+
}
|
|
99
|
+
// Record a truncation notice without clobbering a real error from the executor,
|
|
100
|
+
// and without one truncation form masking the other (both can occur together).
|
|
101
|
+
noteTruncation(task, message) {
|
|
102
|
+
if (task.error?.includes(message))
|
|
103
|
+
return;
|
|
104
|
+
task.error = task.error ? `${task.error}; ${message}` : message;
|
|
105
|
+
}
|
|
106
|
+
trimOutput(task) {
|
|
107
|
+
if (task.outputChunks.length > MAX_TASK_OUTPUT_CHUNKS) {
|
|
108
|
+
const excess = task.outputChunks.length - MAX_TASK_OUTPUT_CHUNKS;
|
|
109
|
+
task.outputChunks.splice(0, excess);
|
|
110
|
+
this.noteTruncation(task, 'Task output chunk count exceeded the maximum and was truncated.');
|
|
111
|
+
}
|
|
112
|
+
task.output = task.outputChunks.join('\n');
|
|
113
|
+
if (task.output.length <= MAX_TASK_OUTPUT_CHARS)
|
|
114
|
+
return;
|
|
115
|
+
while (task.output.length > MAX_TASK_OUTPUT_CHARS && task.outputChunks.length > 1) {
|
|
116
|
+
task.outputChunks.shift();
|
|
117
|
+
task.output = task.outputChunks.join('\n');
|
|
118
|
+
}
|
|
119
|
+
if (task.outputChunks.length === 1 && task.outputChunks[0].length > MAX_TASK_OUTPUT_CHARS) {
|
|
120
|
+
task.outputChunks[0] = task.outputChunks[0].slice(-MAX_TASK_OUTPUT_CHARS);
|
|
121
|
+
task.output = task.outputChunks[0];
|
|
122
|
+
}
|
|
123
|
+
this.noteTruncation(task, 'Task output exceeded the maximum size and was truncated.');
|
|
124
|
+
}
|
|
125
|
+
enforceRetention() {
|
|
126
|
+
if (this.tasks.size <= MAX_TASKS)
|
|
127
|
+
return;
|
|
128
|
+
const finished = [...this.tasks.values()]
|
|
129
|
+
.filter((task) => ['succeeded', 'failed', 'cancelled'].includes(task.status))
|
|
130
|
+
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
131
|
+
const toRemove = Math.min(finished.length, this.tasks.size - MAX_TASKS);
|
|
132
|
+
for (const task of finished.slice(0, toRemove)) {
|
|
133
|
+
this.tasks.delete(task.id);
|
|
134
|
+
}
|
|
135
|
+
// Never evict a running/pending task to reclaim a slot: aborting and deleting an
|
|
136
|
+
// in-flight job would silently kill someone else's work and leave it unqueryable
|
|
137
|
+
// (`get()` returns undefined rather than a terminal `cancelled` snapshot). When
|
|
138
|
+
// only running tasks remain, the map is allowed to exceed MAX_TASKS temporarily;
|
|
139
|
+
// the overflow is bounded by the number of concurrently in-flight tasks and is
|
|
140
|
+
// reclaimed as they finish and become eligible for eviction above.
|
|
141
|
+
}
|
|
142
|
+
withTimeout(promise, ms) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const timer = setTimeout(() => reject(new Error(`cancel hook timed out after ${ms}ms`)), ms);
|
|
145
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
snapshot(task) {
|
|
149
|
+
return {
|
|
150
|
+
id: task.id,
|
|
151
|
+
kind: task.kind,
|
|
152
|
+
status: task.status,
|
|
153
|
+
createdAt: task.createdAt,
|
|
154
|
+
startedAt: task.startedAt,
|
|
155
|
+
updatedAt: task.updatedAt,
|
|
156
|
+
finishedAt: task.finishedAt,
|
|
157
|
+
output: task.output,
|
|
158
|
+
outputChunks: task.outputChunks.slice(-MAX_TASK_OUTPUT_CHUNKS),
|
|
159
|
+
error: task.error,
|
|
160
|
+
metadata: task.metadata ? { ...task.metadata } : undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export const taskStore = new TaskStore();
|