@rucher/carnet 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/carnet-CSdqWDas.d.mts +261 -0
- package/dist/carnet-CSdqWDas.d.mts.map +1 -0
- package/dist/index.d.mts +65 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +71 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mcp.d.mts +40 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +417 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/resume-Daotqfdq.mjs +985 -0
- package/dist/resume-Daotqfdq.mjs.map +1 -0
- package/package.json +54 -3
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
// src/context.ts
|
|
6
|
+
function nowIso(ctx) {
|
|
7
|
+
return ctx.now().toISOString();
|
|
8
|
+
}
|
|
9
|
+
// src/errors.ts
|
|
10
|
+
var CarnetError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "CarnetError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
// src/events.ts
|
|
19
|
+
const EVENT_TYPES = [
|
|
20
|
+
"started",
|
|
21
|
+
"finished",
|
|
22
|
+
"answered",
|
|
23
|
+
"applied",
|
|
24
|
+
"tested",
|
|
25
|
+
"committed",
|
|
26
|
+
"dropped"
|
|
27
|
+
];
|
|
28
|
+
function isEventType(value) {
|
|
29
|
+
return typeof value === "string" && EVENT_TYPES.includes(value);
|
|
30
|
+
}
|
|
31
|
+
function toEvent(row) {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
session: row.session,
|
|
35
|
+
at: row.at,
|
|
36
|
+
type: row.type,
|
|
37
|
+
agent: row.agent,
|
|
38
|
+
commit: row.commit_sha,
|
|
39
|
+
link: row.link,
|
|
40
|
+
note: row.note
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function logEvent(ctx, session, input) {
|
|
44
|
+
if (!isEventType(input.type)) throw new CarnetError("INVALID_EVENT_TYPE", `Unknown event type "${String(input.type)}"`);
|
|
45
|
+
const result = ctx.db.prepare("INSERT INTO events (session, at, type, agent, commit_sha, link, note) VALUES (?, ?, ?, ?, ?, ?, ?)").run(session, nowIso(ctx), input.type, input.agent ?? null, input.commit ?? null, input.link ?? null, input.note ?? null);
|
|
46
|
+
return toEvent(ctx.db.prepare("SELECT * FROM events WHERE id = ?").get(result.lastInsertRowid));
|
|
47
|
+
}
|
|
48
|
+
function readEvents(ctx, { session, limit = 20 }) {
|
|
49
|
+
return ctx.db.prepare(`SELECT * FROM (SELECT * FROM events WHERE session = ? ORDER BY id DESC LIMIT ?) ORDER BY id ASC`).all(session, limit).map(toEvent);
|
|
50
|
+
}
|
|
51
|
+
// src/status/model.ts
|
|
52
|
+
const AGENT_STATUSES = [
|
|
53
|
+
"running",
|
|
54
|
+
"discussing",
|
|
55
|
+
"answer",
|
|
56
|
+
"review",
|
|
57
|
+
"apply",
|
|
58
|
+
"test",
|
|
59
|
+
"commit",
|
|
60
|
+
"branch",
|
|
61
|
+
"closed"
|
|
62
|
+
];
|
|
63
|
+
const USER_ACTION_STATUSES = [
|
|
64
|
+
"discussing",
|
|
65
|
+
"answer",
|
|
66
|
+
"review",
|
|
67
|
+
"apply",
|
|
68
|
+
"test",
|
|
69
|
+
"commit",
|
|
70
|
+
"branch"
|
|
71
|
+
];
|
|
72
|
+
const EMOJI = {
|
|
73
|
+
running: "⏳",
|
|
74
|
+
discussing: "💬",
|
|
75
|
+
answer: "❓",
|
|
76
|
+
review: "👀",
|
|
77
|
+
apply: "📥",
|
|
78
|
+
test: "🧪",
|
|
79
|
+
commit: "✅",
|
|
80
|
+
branch: "🌿"
|
|
81
|
+
};
|
|
82
|
+
const MAIN_WORKING_EMOJI = "⚙️";
|
|
83
|
+
const LEGEND = "⚙️ main working · 💬 discussing · ❓ answer · 👀 review · 📥 apply · 🧪 test · ✅ commit · 🌿 branch · ⏳ running";
|
|
84
|
+
function isAgentStatus(value) {
|
|
85
|
+
return typeof value === "string" && AGENT_STATUSES.includes(value);
|
|
86
|
+
}
|
|
87
|
+
function isUserActionStatus(value) {
|
|
88
|
+
return typeof value === "string" && USER_ACTION_STATUSES.includes(value);
|
|
89
|
+
}
|
|
90
|
+
const OPEN_AGENT_STATUSES = ["running", ...USER_ACTION_STATUSES];
|
|
91
|
+
// src/status/note.ts
|
|
92
|
+
const NOTE_MAX = 60;
|
|
93
|
+
const PATH_PREFIX = /^(\/|\.\.?\/|~\/)/;
|
|
94
|
+
const FILE_AFTER_SLASH = /\/[^/\s]+\.[A-Za-z0-9]+$/;
|
|
95
|
+
function looksLikePath(token) {
|
|
96
|
+
if (PATH_PREFIX.test(token)) return true;
|
|
97
|
+
const slashes = token.split("/").length - 1;
|
|
98
|
+
return slashes >= 2 || slashes === 1 && FILE_AFTER_SLASH.test(token);
|
|
99
|
+
}
|
|
100
|
+
function sanitizeNote(text) {
|
|
101
|
+
const cleaned = text.replaceAll("`", "").split(/\s+/).filter((token) => token && !looksLikePath(token)).join(" ").trim();
|
|
102
|
+
if (cleaned.length <= 60) return cleaned;
|
|
103
|
+
return `${cleaned.slice(0, 59).trimEnd()}…`;
|
|
104
|
+
}
|
|
105
|
+
// src/agents.ts
|
|
106
|
+
function toAgent(row) {
|
|
107
|
+
return {
|
|
108
|
+
session: row.session,
|
|
109
|
+
name: row.name,
|
|
110
|
+
task: row.task,
|
|
111
|
+
status: row.status,
|
|
112
|
+
note: row.note,
|
|
113
|
+
reportPath: row.report_path,
|
|
114
|
+
baseCommit: row.base_commit,
|
|
115
|
+
worktree: row.worktree,
|
|
116
|
+
queueOrder: row.queue_order,
|
|
117
|
+
hostAgentId: row.host_agent_id,
|
|
118
|
+
pendingStatus: row.pending_status,
|
|
119
|
+
doneAt: row.done_at,
|
|
120
|
+
startedAt: row.started_at,
|
|
121
|
+
updatedAt: row.updated_at
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const REPORTING_NOTE = "reporting";
|
|
125
|
+
const PUBLISH_FALLBACK_MILLISECONDS = 12e4;
|
|
126
|
+
function publishPending(ctx, session, name) {
|
|
127
|
+
const row = ctx.db.prepare("SELECT pending_status, report_path FROM agents WHERE session = ? AND name = ?").get(session, name);
|
|
128
|
+
if (!row?.pending_status) return;
|
|
129
|
+
ctx.db.prepare("UPDATE agents SET status = pending_status, pending_status = NULL, note = NULL, updated_at = ? WHERE session = ? AND name = ?").run(nowIso(ctx), session, name);
|
|
130
|
+
logEvent(ctx, session, {
|
|
131
|
+
type: "finished",
|
|
132
|
+
agent: name,
|
|
133
|
+
link: row.report_path ?? void 0
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function publishOverdue(ctx, session) {
|
|
137
|
+
const before = (/* @__PURE__ */ new Date(ctx.now().getTime() - PUBLISH_FALLBACK_MILLISECONDS)).toISOString();
|
|
138
|
+
const rows = ctx.db.prepare("SELECT name FROM agents WHERE session = ? AND pending_status IS NOT NULL AND done_at <= ?").all(session, before);
|
|
139
|
+
for (const row of rows) publishPending(ctx, session, row.name);
|
|
140
|
+
}
|
|
141
|
+
function getAgent(ctx, session, name) {
|
|
142
|
+
publishOverdue(ctx, session);
|
|
143
|
+
const row = ctx.db.prepare("SELECT * FROM agents WHERE session = ? AND name = ?").get(session, name);
|
|
144
|
+
return row && toAgent(row);
|
|
145
|
+
}
|
|
146
|
+
function requireAgent(ctx, session, name) {
|
|
147
|
+
const agent = getAgent(ctx, session, name);
|
|
148
|
+
if (!agent) throw new CarnetError("AGENT_NOT_FOUND", `No agent "${name}" in this session`);
|
|
149
|
+
return agent;
|
|
150
|
+
}
|
|
151
|
+
function startAgent(ctx, session, input) {
|
|
152
|
+
const name = sanitizeNote(input.name);
|
|
153
|
+
const existing = getAgent(ctx, session, name);
|
|
154
|
+
if (existing && existing.status !== "closed") throw new CarnetError("AGENT_EXISTS", `Agent "${name}" is already open in this session`);
|
|
155
|
+
if (existing) ctx.db.prepare("DELETE FROM agents WHERE session = ? AND name = ?").run(session, name);
|
|
156
|
+
const at = nowIso(ctx);
|
|
157
|
+
ctx.db.prepare(`INSERT INTO agents (session, name, task, status, base_commit, worktree, started_at, updated_at)
|
|
158
|
+
VALUES (?, ?, ?, 'running', ?, ?, ?, ?)`).run(session, name, input.task, input.baseCommit ?? null, input.worktree ?? null, at, at);
|
|
159
|
+
logEvent(ctx, session, {
|
|
160
|
+
type: "started",
|
|
161
|
+
agent: name,
|
|
162
|
+
note: input.task
|
|
163
|
+
});
|
|
164
|
+
return requireAgent(ctx, session, name);
|
|
165
|
+
}
|
|
166
|
+
function updateAgent(ctx, session, name, input) {
|
|
167
|
+
const current = requireAgent(ctx, session, name);
|
|
168
|
+
if (!isAgentStatus(input.status) || input.status === "closed") throw new CarnetError("INVALID_STATUS", `Invalid agent status "${input.status}"; use agent_drop to close`);
|
|
169
|
+
const note = input.note === void 0 ? null : sanitizeNote(input.note);
|
|
170
|
+
if (current.pendingStatus) publishPending(ctx, session, name);
|
|
171
|
+
ctx.db.prepare("UPDATE agents SET note = COALESCE(?, CASE WHEN status = 'running' AND ? <> 'running' THEN NULL ELSE note END), status = ?, report_path = COALESCE(?, report_path), updated_at = ? WHERE session = ? AND name = ?").run(note, input.status, input.status, input.reportPath ?? null, nowIso(ctx), session, name);
|
|
172
|
+
return requireAgent(ctx, session, name);
|
|
173
|
+
}
|
|
174
|
+
function finishAgent(ctx, session, name, input) {
|
|
175
|
+
const agent = requireAgent(ctx, session, name);
|
|
176
|
+
const status = input.hasQuestions ? "answer" : "review";
|
|
177
|
+
const at = nowIso(ctx);
|
|
178
|
+
ctx.db.prepare("UPDATE agents SET pending_status = ?, note = ?, report_path = ?, done_at = ?, updated_at = ? WHERE session = ? AND name = ?").run(status, REPORTING_NOTE, input.reportPath, at, at, session, name);
|
|
179
|
+
if (!agent.hostAgentId) publishPending(ctx, session, name);
|
|
180
|
+
return requireAgent(ctx, session, name);
|
|
181
|
+
}
|
|
182
|
+
function dropAgent(ctx, session, name, reason) {
|
|
183
|
+
requireAgent(ctx, session, name);
|
|
184
|
+
ctx.db.prepare("UPDATE agents SET status = 'closed', queue_order = NULL, updated_at = ? WHERE session = ? AND name = ?").run(nowIso(ctx), session, name);
|
|
185
|
+
logEvent(ctx, session, {
|
|
186
|
+
type: "dropped",
|
|
187
|
+
agent: name,
|
|
188
|
+
note: reason
|
|
189
|
+
});
|
|
190
|
+
return requireAgent(ctx, session, name);
|
|
191
|
+
}
|
|
192
|
+
function listAgents(ctx, session, options = {}) {
|
|
193
|
+
publishOverdue(ctx, session);
|
|
194
|
+
const where = options.includeClosed ? "" : "AND status != 'closed'";
|
|
195
|
+
return ctx.db.prepare(`SELECT * FROM agents WHERE session = ? ${where} ORDER BY started_at ASC, name ASC`).all(session).map(toAgent);
|
|
196
|
+
}
|
|
197
|
+
function reorderQueue(ctx, session, names) {
|
|
198
|
+
const at = nowIso(ctx);
|
|
199
|
+
ctx.db.prepare("UPDATE agents SET queue_order = NULL WHERE session = ?").run(session);
|
|
200
|
+
const update = ctx.db.prepare("UPDATE agents SET queue_order = ?, updated_at = ? WHERE session = ? AND name = ?");
|
|
201
|
+
names.forEach((name, index) => {
|
|
202
|
+
update.run(index + 1, at, session, sanitizeNote(name));
|
|
203
|
+
});
|
|
204
|
+
return listAgents(ctx, session);
|
|
205
|
+
}
|
|
206
|
+
// src/db/schema.ts
|
|
207
|
+
const migrations = [
|
|
208
|
+
`
|
|
209
|
+
CREATE TABLE sessions (
|
|
210
|
+
id TEXT PRIMARY KEY,
|
|
211
|
+
name TEXT UNIQUE,
|
|
212
|
+
purpose TEXT,
|
|
213
|
+
status TEXT NOT NULL DEFAULT 'temporary'
|
|
214
|
+
CHECK (status IN ('temporary', 'active', 'dropped')),
|
|
215
|
+
cwd TEXT,
|
|
216
|
+
repo TEXT,
|
|
217
|
+
claude_pid INTEGER,
|
|
218
|
+
created_at TEXT NOT NULL,
|
|
219
|
+
last_active_at TEXT NOT NULL
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
CREATE TABLE agents (
|
|
223
|
+
session TEXT NOT NULL REFERENCES sessions(id),
|
|
224
|
+
name TEXT NOT NULL,
|
|
225
|
+
task TEXT,
|
|
226
|
+
status TEXT NOT NULL
|
|
227
|
+
CHECK (status IN ('running', 'discussing', 'answer', 'review', 'apply', 'test', 'commit', 'branch', 'closed')),
|
|
228
|
+
note TEXT,
|
|
229
|
+
report_path TEXT,
|
|
230
|
+
base_commit TEXT,
|
|
231
|
+
worktree TEXT,
|
|
232
|
+
queue_order INTEGER,
|
|
233
|
+
started_at TEXT NOT NULL,
|
|
234
|
+
updated_at TEXT NOT NULL,
|
|
235
|
+
PRIMARY KEY (session, name)
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
CREATE TABLE table_items (
|
|
239
|
+
session TEXT PRIMARY KEY REFERENCES sessions(id),
|
|
240
|
+
text TEXT NOT NULL,
|
|
241
|
+
status TEXT,
|
|
242
|
+
main_working INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
updated_at TEXT NOT NULL
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
CREATE TABLE events (
|
|
247
|
+
id INTEGER PRIMARY KEY,
|
|
248
|
+
session TEXT NOT NULL REFERENCES sessions(id),
|
|
249
|
+
at TEXT NOT NULL,
|
|
250
|
+
type TEXT NOT NULL
|
|
251
|
+
CHECK (type IN ('started', 'finished', 'answered', 'applied', 'tested', 'committed', 'dropped')),
|
|
252
|
+
agent TEXT,
|
|
253
|
+
commit_sha TEXT,
|
|
254
|
+
link TEXT,
|
|
255
|
+
note TEXT
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
CREATE INDEX events_session_at ON events(session, at);
|
|
259
|
+
CREATE INDEX agents_session_status ON agents(session, status);
|
|
260
|
+
`,
|
|
261
|
+
`
|
|
262
|
+
ALTER TABLE agents ADD COLUMN claude_agent_id TEXT;
|
|
263
|
+
`,
|
|
264
|
+
`
|
|
265
|
+
ALTER TABLE sessions ADD COLUMN hooked_at TEXT;
|
|
266
|
+
CREATE INDEX sessions_claude_pid ON sessions(claude_pid, hooked_at);
|
|
267
|
+
`,
|
|
268
|
+
`
|
|
269
|
+
ALTER TABLE agents ADD COLUMN pending_status TEXT;
|
|
270
|
+
ALTER TABLE agents ADD COLUMN done_at TEXT;
|
|
271
|
+
`,
|
|
272
|
+
`
|
|
273
|
+
UPDATE sessions SET status = 'active' WHERE status = 'paused';
|
|
274
|
+
`,
|
|
275
|
+
`
|
|
276
|
+
ALTER TABLE sessions RENAME COLUMN claude_pid TO host_pid;
|
|
277
|
+
ALTER TABLE sessions ADD COLUMN host TEXT NOT NULL DEFAULT 'claude-code';
|
|
278
|
+
ALTER TABLE agents RENAME COLUMN claude_agent_id TO host_agent_id;
|
|
279
|
+
DROP INDEX sessions_claude_pid;
|
|
280
|
+
CREATE INDEX sessions_host_pid ON sessions(host, host_pid, hooked_at);
|
|
281
|
+
`
|
|
282
|
+
];
|
|
283
|
+
function schemaVersion(db) {
|
|
284
|
+
return db.prepare("PRAGMA user_version").get().user_version;
|
|
285
|
+
}
|
|
286
|
+
function migrate(db) {
|
|
287
|
+
const current = schemaVersion(db);
|
|
288
|
+
for (const [version, migration] of migrations.entries()) {
|
|
289
|
+
if (version < current) continue;
|
|
290
|
+
db.exec("BEGIN");
|
|
291
|
+
try {
|
|
292
|
+
db.exec(migration);
|
|
293
|
+
db.exec(`PRAGMA user_version = ${version + 1}`);
|
|
294
|
+
db.exec("COMMIT");
|
|
295
|
+
} catch (error) {
|
|
296
|
+
db.exec("ROLLBACK");
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// src/db/open.ts
|
|
302
|
+
const MEMORY_DB = ":memory:";
|
|
303
|
+
function openDatabase(path) {
|
|
304
|
+
const db = new DatabaseSync(path);
|
|
305
|
+
if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
|
|
306
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
307
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
308
|
+
migrate(db);
|
|
309
|
+
return db;
|
|
310
|
+
}
|
|
311
|
+
// src/hosts/claude-code.ts
|
|
312
|
+
const TRANSCRIPT_TAIL_BYTES = 262144;
|
|
313
|
+
const TITLE_PATTERN = /"type":"custom-title","customTitle":("(?:[^"\\]|\\.)*")/g;
|
|
314
|
+
const REPOS_START = "<!-- rucher:repos start -->";
|
|
315
|
+
const REPOS_END = "<!-- rucher:repos end -->";
|
|
316
|
+
function payloadObject(json) {
|
|
317
|
+
const data = JSON.parse(json);
|
|
318
|
+
if (typeof data.session_id !== "string" || !data.session_id) throw new TypeError("Hook payload has no session_id");
|
|
319
|
+
return data;
|
|
320
|
+
}
|
|
321
|
+
function text(data, key) {
|
|
322
|
+
const value = data[key];
|
|
323
|
+
return typeof value === "string" ? value : void 0;
|
|
324
|
+
}
|
|
325
|
+
function transcriptTitle(path) {
|
|
326
|
+
let descriptor;
|
|
327
|
+
try {
|
|
328
|
+
descriptor = openSync(path, "r");
|
|
329
|
+
const size = fstatSync(descriptor).size;
|
|
330
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
331
|
+
const buffer = Buffer.alloc(length);
|
|
332
|
+
readSync(descriptor, buffer, 0, length, size - length);
|
|
333
|
+
const last = [...buffer.toString("utf8").matchAll(TITLE_PATTERN)].at(-1)?.[1];
|
|
334
|
+
return last === void 0 ? void 0 : JSON.parse(last);
|
|
335
|
+
} catch {
|
|
336
|
+
return;
|
|
337
|
+
} finally {
|
|
338
|
+
if (descriptor !== void 0) closeSync(descriptor);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function reposBlock(group) {
|
|
342
|
+
const repos = group.repos.length ? group.repos.map((repo) => `- \`${basename(repo)}\`: ${repo}`).join("\n") : "- (none yet: `rucher group add <path>`)";
|
|
343
|
+
return `${REPOS_START}\n## Repos\n\n${repos}\n${REPOS_END}`;
|
|
344
|
+
}
|
|
345
|
+
function replaceReposBlock(content, group) {
|
|
346
|
+
const start = content.indexOf(REPOS_START);
|
|
347
|
+
const end = content.indexOf(REPOS_END);
|
|
348
|
+
if (start === -1 || end === -1 || end < start) return null;
|
|
349
|
+
return content.slice(0, start) + reposBlock(group) + content.slice(end + 25);
|
|
350
|
+
}
|
|
351
|
+
function groupInstructions(group) {
|
|
352
|
+
return `# ${group.name}
|
|
353
|
+
|
|
354
|
+
Meta directory of the ${group.name} group: context only, the repos live at their own paths below and are reachable through \`.claude/settings.json\`. Start Claude Code sessions here.
|
|
355
|
+
|
|
356
|
+
${reposBlock(group)}
|
|
357
|
+
|
|
358
|
+
## Multi-agent work
|
|
359
|
+
|
|
360
|
+
Use the rucher tools (Carnet) for agent statuses and the journal, and the rucher skill for the workflow.
|
|
361
|
+
|
|
362
|
+
## Shared instructions
|
|
363
|
+
|
|
364
|
+
(what every session of this group should know)
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
function groupSettings(group) {
|
|
368
|
+
return `${JSON.stringify({ permissions: { additionalDirectories: group.repos } }, null, 2)}\n`;
|
|
369
|
+
}
|
|
370
|
+
const claudeCode = {
|
|
371
|
+
id: "claude-code",
|
|
372
|
+
displayName: "Claude Code",
|
|
373
|
+
processId(env) {
|
|
374
|
+
const processId = Number(env.CLAUDE_PID);
|
|
375
|
+
return Number.isInteger(processId) && processId > 0 ? processId : null;
|
|
376
|
+
},
|
|
377
|
+
sessionIdFromEnvironment(env) {
|
|
378
|
+
return env.CLAUDE_CODE_SESSION_ID || void 0;
|
|
379
|
+
},
|
|
380
|
+
projectDirectory(env) {
|
|
381
|
+
return env.CLAUDE_PROJECT_DIR || void 0;
|
|
382
|
+
},
|
|
383
|
+
parseSessionPayload(json) {
|
|
384
|
+
const data = payloadObject(json);
|
|
385
|
+
return {
|
|
386
|
+
session_id: data.session_id,
|
|
387
|
+
cwd: text(data, "cwd"),
|
|
388
|
+
source: text(data, "source"),
|
|
389
|
+
session_title: text(data, "session_title")
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
parsePromptPayload(json) {
|
|
393
|
+
const data = payloadObject(json);
|
|
394
|
+
return {
|
|
395
|
+
session_id: data.session_id,
|
|
396
|
+
cwd: text(data, "cwd"),
|
|
397
|
+
session_title: text(data, "session_title"),
|
|
398
|
+
transcript_path: text(data, "transcript_path")
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
parseSubagentPayload(json) {
|
|
402
|
+
const data = payloadObject(json);
|
|
403
|
+
const agentId = text(data, "agent_id");
|
|
404
|
+
if (!agentId) throw new TypeError("Hook payload has no agent_id");
|
|
405
|
+
return {
|
|
406
|
+
session_id: data.session_id,
|
|
407
|
+
agent_id: agentId,
|
|
408
|
+
agent_type: text(data, "agent_type"),
|
|
409
|
+
cwd: text(data, "cwd")
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
promptTitle(payload) {
|
|
413
|
+
return payload.session_title ?? (payload.transcript_path ? transcriptTitle(payload.transcript_path) : void 0);
|
|
414
|
+
},
|
|
415
|
+
resumeCommand(session) {
|
|
416
|
+
return {
|
|
417
|
+
command: "claude",
|
|
418
|
+
args: ["--resume", session.id]
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
renameInstruction(name) {
|
|
422
|
+
return `Ask the human to run /rename ${name} in Claude Code so the titles match.`;
|
|
423
|
+
},
|
|
424
|
+
contextFiles: [{
|
|
425
|
+
path: ".claude/settings.json",
|
|
426
|
+
sync(group, mode) {
|
|
427
|
+
const path = join(group.root, ".claude", "settings.json");
|
|
428
|
+
if (!existsSync(path)) {
|
|
429
|
+
if (group.repos.length === 0) return "unchanged";
|
|
430
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
431
|
+
writeFileSync(path, groupSettings(group));
|
|
432
|
+
return "written";
|
|
433
|
+
}
|
|
434
|
+
const settings = JSON.parse(readFileSync(path, "utf8"));
|
|
435
|
+
const current = settings.permissions?.additionalDirectories ?? [];
|
|
436
|
+
const next = mode === "exact" ? group.repos : [...current, ...group.repos.filter((repo) => !current.includes(repo))];
|
|
437
|
+
if (next.length === current.length && next.every((repo, index) => repo === current[index])) return "unchanged";
|
|
438
|
+
settings.permissions = {
|
|
439
|
+
...settings.permissions,
|
|
440
|
+
additionalDirectories: next
|
|
441
|
+
};
|
|
442
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
|
|
443
|
+
return "updated";
|
|
444
|
+
}
|
|
445
|
+
}, {
|
|
446
|
+
path: "CLAUDE.md",
|
|
447
|
+
sync(group) {
|
|
448
|
+
const path = join(group.root, "CLAUDE.md");
|
|
449
|
+
if (!existsSync(path)) {
|
|
450
|
+
writeFileSync(path, groupInstructions(group));
|
|
451
|
+
return "written";
|
|
452
|
+
}
|
|
453
|
+
const content = readFileSync(path, "utf8");
|
|
454
|
+
const next = replaceReposBlock(content, group);
|
|
455
|
+
if (next === null) return "no-markers";
|
|
456
|
+
if (next === content) return "unchanged";
|
|
457
|
+
writeFileSync(path, next);
|
|
458
|
+
return "updated";
|
|
459
|
+
}
|
|
460
|
+
}]
|
|
461
|
+
};
|
|
462
|
+
// src/hosts/host.ts
|
|
463
|
+
const DEFAULT_HOST = "claude-code";
|
|
464
|
+
const HOSTS = { [claudeCode.id]: claudeCode };
|
|
465
|
+
function allHosts() {
|
|
466
|
+
return Object.values(HOSTS);
|
|
467
|
+
}
|
|
468
|
+
function hostById(id) {
|
|
469
|
+
const host = HOSTS[id];
|
|
470
|
+
if (!host) throw new CarnetError("UNKNOWN_HOST", `Unknown host "${id}" (available: ${Object.keys(HOSTS).join(", ")})`);
|
|
471
|
+
return host;
|
|
472
|
+
}
|
|
473
|
+
// src/group/registry.ts
|
|
474
|
+
function rucherHome(env = process.env) {
|
|
475
|
+
return env.RUCHER_HOME ?? join(homedir(), ".rucher");
|
|
476
|
+
}
|
|
477
|
+
function registryPath(env) {
|
|
478
|
+
return join(rucherHome(env), "groups.json");
|
|
479
|
+
}
|
|
480
|
+
function readRegistry(env) {
|
|
481
|
+
const file = registryPath(env);
|
|
482
|
+
if (!existsSync(file)) return [];
|
|
483
|
+
return JSON.parse(readFileSync(file, "utf8")).groups ?? [];
|
|
484
|
+
}
|
|
485
|
+
function writeRegistry(groups, env) {
|
|
486
|
+
groups.sort((left, right) => left.name.localeCompare(right.name) || left.path.localeCompare(right.path));
|
|
487
|
+
mkdirSync(rucherHome(env), { recursive: true });
|
|
488
|
+
writeFileSync(registryPath(env), `${JSON.stringify({ groups }, null, 2)}\n`);
|
|
489
|
+
return groups;
|
|
490
|
+
}
|
|
491
|
+
function registerGroup(reference, env) {
|
|
492
|
+
return writeRegistry([...readRegistry(env).filter((group) => group.path !== reference.path), reference], env);
|
|
493
|
+
}
|
|
494
|
+
function unregisterGroups(paths, env) {
|
|
495
|
+
return writeRegistry(readRegistry(env).filter((group) => !paths.includes(group.path)), env);
|
|
496
|
+
}
|
|
497
|
+
// src/group/locate.ts
|
|
498
|
+
const GROUP_DIRECTORY = ".rucher";
|
|
499
|
+
const REPORTS_DIRECTORY = "reports";
|
|
500
|
+
const GROUP_FILE = "group.json";
|
|
501
|
+
const DB_FILE = "carnet.db";
|
|
502
|
+
function isInside(directory, path) {
|
|
503
|
+
const relativePath = relative(directory, path);
|
|
504
|
+
return relativePath === "" || !relativePath.startsWith("..") && !resolve(relativePath).startsWith(relativePath);
|
|
505
|
+
}
|
|
506
|
+
function repoNameFor(group, path) {
|
|
507
|
+
if (!path) return null;
|
|
508
|
+
const repo = group.repos.find((repo) => isInside(repo, resolve(path)));
|
|
509
|
+
return repo ? basename(repo) : null;
|
|
510
|
+
}
|
|
511
|
+
function repoPathFor(group, path) {
|
|
512
|
+
if (!path) return null;
|
|
513
|
+
const absolutePath = resolve(path);
|
|
514
|
+
const repo = group.repos.find((candidate) => isInside(candidate, absolutePath));
|
|
515
|
+
if (!repo) return null;
|
|
516
|
+
const relativePath = relative(repo, absolutePath);
|
|
517
|
+
return relativePath ? join(basename(repo), relativePath) : basename(repo);
|
|
518
|
+
}
|
|
519
|
+
function findGroupRoot(from) {
|
|
520
|
+
let directory = resolve(from);
|
|
521
|
+
for (;;) {
|
|
522
|
+
if (isGroupRoot(directory)) return directory;
|
|
523
|
+
const parent = dirname(directory);
|
|
524
|
+
if (parent === directory) return null;
|
|
525
|
+
directory = parent;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function reportsDirectoryOf(group) {
|
|
529
|
+
return join(group.root, GROUP_DIRECTORY, REPORTS_DIRECTORY);
|
|
530
|
+
}
|
|
531
|
+
function reportPathFor(group, agentName) {
|
|
532
|
+
return join(reportsDirectoryOf(group), `${agentName}.md`);
|
|
533
|
+
}
|
|
534
|
+
function isGroupRoot(directory) {
|
|
535
|
+
return existsSync(join(directory, GROUP_DIRECTORY, GROUP_FILE));
|
|
536
|
+
}
|
|
537
|
+
function loadGroup(root) {
|
|
538
|
+
const file = JSON.parse(readFileSync(join(root, GROUP_DIRECTORY, GROUP_FILE), "utf8"));
|
|
539
|
+
return {
|
|
540
|
+
root,
|
|
541
|
+
name: file.name,
|
|
542
|
+
repos: (file.repos ?? []).map((repo) => resolve(root, repo)),
|
|
543
|
+
hosts: file.hosts?.length ? file.hosts : [DEFAULT_HOST],
|
|
544
|
+
dbPath: join(root, GROUP_DIRECTORY, DB_FILE)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function groupByRepo(path, env) {
|
|
548
|
+
for (const reference of readRegistry(env)) {
|
|
549
|
+
if (!isGroupRoot(reference.path)) continue;
|
|
550
|
+
const group = loadGroup(reference.path);
|
|
551
|
+
if (group.repos.some((repo) => isInside(repo, path))) return group;
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
function locateGroup({ cwd = process.cwd(), env = process.env } = {}) {
|
|
556
|
+
const projectDirectories = allHosts().map((host) => host.projectDirectory(env)).filter((directory) => directory !== void 0);
|
|
557
|
+
const candidates = [resolve(cwd), ...projectDirectories.map((directory) => resolve(directory))];
|
|
558
|
+
for (const directory of candidates) {
|
|
559
|
+
const root = findGroupRoot(directory);
|
|
560
|
+
if (root) return loadGroup(root);
|
|
561
|
+
}
|
|
562
|
+
for (const directory of candidates) {
|
|
563
|
+
const group = groupByRepo(directory, env);
|
|
564
|
+
if (group) return group;
|
|
565
|
+
}
|
|
566
|
+
throw new CarnetError("NO_GROUP", `No Rucher group for ${cwd}: run "rucher init" in a meta directory, or "rucher group add <this repo>" there`);
|
|
567
|
+
}
|
|
568
|
+
// src/sessions.ts
|
|
569
|
+
const COLUMNS = "id, name, purpose, status, cwd, repo, host, host_pid, hooked_at, created_at, last_active_at";
|
|
570
|
+
function toSession(row) {
|
|
571
|
+
return {
|
|
572
|
+
id: row.id,
|
|
573
|
+
name: row.name,
|
|
574
|
+
purpose: row.purpose,
|
|
575
|
+
status: row.status,
|
|
576
|
+
cwd: row.cwd,
|
|
577
|
+
repo: row.repo,
|
|
578
|
+
host: row.host,
|
|
579
|
+
hostPid: row.host_pid,
|
|
580
|
+
hookedAt: row.hooked_at,
|
|
581
|
+
createdAt: row.created_at,
|
|
582
|
+
lastActiveAt: row.last_active_at
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function getSession(ctx, id) {
|
|
586
|
+
const row = ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions WHERE id = ?`).get(id);
|
|
587
|
+
return row && toSession(row);
|
|
588
|
+
}
|
|
589
|
+
function requireSession(ctx, id) {
|
|
590
|
+
const session = getSession(ctx, id);
|
|
591
|
+
if (!session) throw new CarnetError("SESSION_NOT_FOUND", `Unknown session ${id}`);
|
|
592
|
+
return session;
|
|
593
|
+
}
|
|
594
|
+
function ensureSession(ctx, id, cwd, group) {
|
|
595
|
+
const existing = getSession(ctx, id);
|
|
596
|
+
if (existing) return existing;
|
|
597
|
+
const at = nowIso(ctx);
|
|
598
|
+
ctx.db.prepare("INSERT INTO sessions (id, status, cwd, repo, created_at, last_active_at) VALUES (?, 'temporary', ?, ?, ?, ?)").run(id, cwd ?? null, group ? repoNameFor(group, cwd) : null, at, at);
|
|
599
|
+
return requireSession(ctx, id);
|
|
600
|
+
}
|
|
601
|
+
function titleAvailable(ctx, id, title) {
|
|
602
|
+
const owner = sessionByName(ctx, title);
|
|
603
|
+
return !owner || owner.id === id;
|
|
604
|
+
}
|
|
605
|
+
function adoptTitle(ctx, id, rawTitle) {
|
|
606
|
+
const session = requireSession(ctx, id);
|
|
607
|
+
const title = rawTitle?.trim() || null;
|
|
608
|
+
if (title === null || title === session.name || !titleAvailable(ctx, id, title)) return session;
|
|
609
|
+
ctx.db.prepare(`UPDATE sessions
|
|
610
|
+
SET name = ?, status = CASE WHEN status = 'temporary' THEN 'active' ELSE status END
|
|
611
|
+
WHERE id = ?`).run(title, id);
|
|
612
|
+
return requireSession(ctx, id);
|
|
613
|
+
}
|
|
614
|
+
function upsertFromHook(ctx, group, payload, env = {}) {
|
|
615
|
+
const at = nowIso(ctx);
|
|
616
|
+
const host = env.host ?? "claude-code";
|
|
617
|
+
const hostPid = env.processId ?? null;
|
|
618
|
+
const repo = repoNameFor(group, payload.cwd);
|
|
619
|
+
const existing = getSession(ctx, payload.session_id);
|
|
620
|
+
if (!existing) ctx.db.prepare(`INSERT INTO sessions (id, status, cwd, repo, host, host_pid, hooked_at, created_at, last_active_at)
|
|
621
|
+
VALUES (?, 'temporary', ?, ?, ?, ?, ?, ?, ?)`).run(payload.session_id, payload.cwd ?? null, repo, host, hostPid, at, at, at);
|
|
622
|
+
else ctx.db.prepare(`UPDATE sessions
|
|
623
|
+
SET last_active_at = ?, hooked_at = ?, cwd = ?, repo = ?, host = ?, host_pid = ?
|
|
624
|
+
WHERE id = ?`).run(at, at, payload.cwd ?? existing.cwd, repo ?? existing.repo, host, hostPid ?? existing.hostPid, payload.session_id);
|
|
625
|
+
return adoptTitle(ctx, payload.session_id, payload.session_title);
|
|
626
|
+
}
|
|
627
|
+
function touchSession(ctx, id) {
|
|
628
|
+
ctx.db.prepare("UPDATE sessions SET last_active_at = ? WHERE id = ?").run(nowIso(ctx), id);
|
|
629
|
+
}
|
|
630
|
+
function nameSession(ctx, id, name, purpose) {
|
|
631
|
+
requireSession(ctx, id);
|
|
632
|
+
try {
|
|
633
|
+
ctx.db.prepare(`UPDATE sessions
|
|
634
|
+
SET name = ?, purpose = COALESCE(?, purpose), last_active_at = ?,
|
|
635
|
+
status = CASE WHEN status = 'temporary' THEN 'active' ELSE status END
|
|
636
|
+
WHERE id = ?`).run(name, purpose ?? null, nowIso(ctx), id);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (error instanceof Error && error.message.includes("UNIQUE")) throw new CarnetError("SESSION_NAME_TAKEN", `A session named "${name}" already exists`);
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
return requireSession(ctx, id);
|
|
642
|
+
}
|
|
643
|
+
function listSessions(ctx, options = {}) {
|
|
644
|
+
const excluded = [];
|
|
645
|
+
if (!options.includeTemporary) excluded.push("temporary");
|
|
646
|
+
if (!options.includeDropped) excluded.push("dropped");
|
|
647
|
+
const placeholders = excluded.map(() => "?").join(", ");
|
|
648
|
+
const where = excluded.length ? `WHERE status NOT IN (${placeholders})` : "";
|
|
649
|
+
return ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions ${where} ORDER BY last_active_at DESC`).all(...excluded).map(toSession);
|
|
650
|
+
}
|
|
651
|
+
function dropSession(ctx, id) {
|
|
652
|
+
requireSession(ctx, id);
|
|
653
|
+
const at = nowIso(ctx);
|
|
654
|
+
ctx.db.prepare("UPDATE agents SET status = 'closed', updated_at = ? WHERE session = ? AND status != 'closed'").run(at, id);
|
|
655
|
+
ctx.db.prepare("UPDATE sessions SET status = 'dropped', last_active_at = ? WHERE id = ?").run(at, id);
|
|
656
|
+
return requireSession(ctx, id);
|
|
657
|
+
}
|
|
658
|
+
function defaultSession(ctx) {
|
|
659
|
+
const row = ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions WHERE status != 'dropped' ORDER BY last_active_at DESC LIMIT 1`).get();
|
|
660
|
+
return row && toSession(row);
|
|
661
|
+
}
|
|
662
|
+
function sessionByName(ctx, name) {
|
|
663
|
+
const row = ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions WHERE name = ?`).get(name);
|
|
664
|
+
return row && toSession(row);
|
|
665
|
+
}
|
|
666
|
+
const MINIMUM_ID_PREFIX = 4;
|
|
667
|
+
function shortId(id) {
|
|
668
|
+
return id.slice(0, 8);
|
|
669
|
+
}
|
|
670
|
+
function findSession(ctx, reference) {
|
|
671
|
+
const byName = sessionByName(ctx, reference);
|
|
672
|
+
if (byName) return byName;
|
|
673
|
+
const byId = getSession(ctx, reference);
|
|
674
|
+
if (byId) return byId;
|
|
675
|
+
const isIdPrefix = /^[0-9a-f-]+$/i.test(reference);
|
|
676
|
+
if (isIdPrefix && reference.length >= 4) {
|
|
677
|
+
const rows = ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions WHERE id LIKE ? ORDER BY last_active_at DESC`).all(`${reference.toLowerCase()}%`);
|
|
678
|
+
const [only, ...others] = rows;
|
|
679
|
+
if (only && others.length === 0) return toSession(only);
|
|
680
|
+
if (only) throw new CarnetError("SESSION_AMBIGUOUS", `Session id "${reference}" matches several sessions: ${rows.map((row) => shortId(row.id)).join(", ")}`);
|
|
681
|
+
}
|
|
682
|
+
throw new CarnetError("SESSION_NOT_FOUND", `Unknown session "${reference}"${isIdPrefix && reference.length < 4 ? ` (an id prefix needs at least 4 characters)` : ""}`);
|
|
683
|
+
}
|
|
684
|
+
function sessionByHostProcess(ctx, host, processId) {
|
|
685
|
+
const row = ctx.db.prepare(`SELECT ${COLUMNS} FROM sessions
|
|
686
|
+
WHERE host = ? AND host_pid = ? AND hooked_at IS NOT NULL
|
|
687
|
+
ORDER BY hooked_at DESC LIMIT 1`).get(host, processId);
|
|
688
|
+
return row && toSession(row);
|
|
689
|
+
}
|
|
690
|
+
// src/merge.ts
|
|
691
|
+
function namesOf(ctx, session) {
|
|
692
|
+
return ctx.db.prepare("SELECT name FROM agents WHERE session = ?").all(session).map((row) => row.name);
|
|
693
|
+
}
|
|
694
|
+
function mergeSessions(ctx, fromId, intoId) {
|
|
695
|
+
if (fromId === intoId) throw new CarnetError("SESSION_MERGE_INVALID", "Cannot merge a session into itself");
|
|
696
|
+
const from = requireSession(ctx, fromId);
|
|
697
|
+
const into = requireSession(ctx, intoId);
|
|
698
|
+
const at = nowIso(ctx);
|
|
699
|
+
const taken = new Set(namesOf(ctx, intoId));
|
|
700
|
+
const fromNames = namesOf(ctx, fromId);
|
|
701
|
+
const movedAgents = fromNames.filter((name) => !taken.has(name));
|
|
702
|
+
const closedAgents = fromNames.filter((name) => taken.has(name));
|
|
703
|
+
ctx.db.exec("BEGIN");
|
|
704
|
+
try {
|
|
705
|
+
const moveAgent = ctx.db.prepare("UPDATE agents SET session = ?, updated_at = ? WHERE session = ? AND name = ?");
|
|
706
|
+
for (const name of movedAgents) moveAgent.run(intoId, at, fromId, name);
|
|
707
|
+
ctx.db.prepare("UPDATE agents SET status = 'closed', updated_at = ? WHERE session = ? AND status != 'closed'").run(at, fromId);
|
|
708
|
+
const movedEvents = Number(ctx.db.prepare("UPDATE events SET session = ? WHERE session = ?").run(intoId, fromId).changes);
|
|
709
|
+
const movedTable = !(ctx.db.prepare("SELECT 1 FROM table_items WHERE session = ?").get(intoId) !== void 0) && Number(ctx.db.prepare("UPDATE table_items SET session = ? WHERE session = ?").run(intoId, fromId).changes) > 0;
|
|
710
|
+
ctx.db.prepare("DELETE FROM table_items WHERE session = ?").run(fromId);
|
|
711
|
+
const keepName = into.name === null && from.name !== null;
|
|
712
|
+
ctx.db.prepare("UPDATE sessions SET status = 'dropped', name = NULL, last_active_at = ? WHERE id = ?").run(at, fromId);
|
|
713
|
+
ctx.db.prepare(`UPDATE sessions
|
|
714
|
+
SET name = COALESCE(?, name), purpose = COALESCE(purpose, ?), last_active_at = ?,
|
|
715
|
+
status = CASE WHEN ? AND status = 'temporary' THEN 'active' ELSE status END
|
|
716
|
+
WHERE id = ?`).run(keepName ? from.name : null, from.purpose, at, keepName ? 1 : 0, intoId);
|
|
717
|
+
ctx.db.exec("COMMIT");
|
|
718
|
+
return {
|
|
719
|
+
session: requireSession(ctx, intoId),
|
|
720
|
+
movedAgents,
|
|
721
|
+
closedAgents,
|
|
722
|
+
movedEvents,
|
|
723
|
+
movedTable
|
|
724
|
+
};
|
|
725
|
+
} catch (error) {
|
|
726
|
+
ctx.db.exec("ROLLBACK");
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// src/purge.ts
|
|
731
|
+
const DEFAULT_PURGE_DAYS = 3;
|
|
732
|
+
const DAY_MILLISECONDS = 864e5;
|
|
733
|
+
function purgeDays(value) {
|
|
734
|
+
const days = Number(value);
|
|
735
|
+
return Number.isFinite(days) && days > 0 ? days : 3;
|
|
736
|
+
}
|
|
737
|
+
const PURGEABLE = `
|
|
738
|
+
id != ? AND last_active_at < ?
|
|
739
|
+
AND NOT EXISTS (SELECT 1 FROM events WHERE events.session = sessions.id)
|
|
740
|
+
AND (
|
|
741
|
+
status = 'dropped'
|
|
742
|
+
OR (status = 'temporary' AND NOT EXISTS (SELECT 1 FROM agents WHERE agents.session = sessions.id))
|
|
743
|
+
)`;
|
|
744
|
+
function purgeSessions(ctx, keepId, days) {
|
|
745
|
+
const cutoff = (/* @__PURE__ */ new Date(ctx.now().getTime() - days * DAY_MILLISECONDS)).toISOString();
|
|
746
|
+
ctx.db.exec("BEGIN");
|
|
747
|
+
try {
|
|
748
|
+
const ids = ctx.db.prepare(`SELECT id FROM sessions WHERE ${PURGEABLE}`).all(keepId, cutoff).map((row) => row.id);
|
|
749
|
+
for (const table of ["table_items", "agents"]) {
|
|
750
|
+
const statement = ctx.db.prepare(`DELETE FROM ${table} WHERE session = ?`);
|
|
751
|
+
for (const id of ids) statement.run(id);
|
|
752
|
+
}
|
|
753
|
+
const deleteSession = ctx.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
754
|
+
for (const id of ids) deleteSession.run(id);
|
|
755
|
+
ctx.db.exec("COMMIT");
|
|
756
|
+
return ids;
|
|
757
|
+
} catch (error) {
|
|
758
|
+
ctx.db.exec("ROLLBACK");
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// src/status/render.ts
|
|
763
|
+
const MAIN_WORKING_SUFFIX = "(main working, not ready)";
|
|
764
|
+
function renderTableLine(item) {
|
|
765
|
+
if (item.mainWorking) return `${MAIN_WORKING_EMOJI} ${item.text} ${MAIN_WORKING_SUFFIX}`;
|
|
766
|
+
return `${EMOJI[item.status ?? "discussing"]} ${item.text}`;
|
|
767
|
+
}
|
|
768
|
+
function renderAgentLine(line) {
|
|
769
|
+
return line.note ? `${line.emoji} ${line.name} — ${line.note}` : `${line.emoji} ${line.name}`;
|
|
770
|
+
}
|
|
771
|
+
function renderSections(view) {
|
|
772
|
+
const out = [];
|
|
773
|
+
if (view.table) out.push("On the table", ` ${renderTableLine(view.table)}`, "");
|
|
774
|
+
out.push("Waiting on you");
|
|
775
|
+
if (view.waiting.length === 0) out.push(" nothing");
|
|
776
|
+
view.waiting.forEach((line, index) => {
|
|
777
|
+
out.push(` ${index + 1}. ${renderAgentLine(line)}`);
|
|
778
|
+
});
|
|
779
|
+
out.push("", "Running");
|
|
780
|
+
if (view.running.length === 0) out.push(" nothing");
|
|
781
|
+
for (const line of view.running) out.push(` ${renderAgentLine(line)}`);
|
|
782
|
+
return out.join("\n");
|
|
783
|
+
}
|
|
784
|
+
function renderStatus(view) {
|
|
785
|
+
return [
|
|
786
|
+
view.time,
|
|
787
|
+
"",
|
|
788
|
+
renderSections(view),
|
|
789
|
+
"",
|
|
790
|
+
LEGEND
|
|
791
|
+
].join("\n");
|
|
792
|
+
}
|
|
793
|
+
// src/table.ts
|
|
794
|
+
function toItem(row) {
|
|
795
|
+
return {
|
|
796
|
+
session: row.session,
|
|
797
|
+
text: row.text,
|
|
798
|
+
status: row.status,
|
|
799
|
+
mainWorking: row.main_working === 1,
|
|
800
|
+
updatedAt: row.updated_at
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function getTable(ctx, session) {
|
|
804
|
+
const row = ctx.db.prepare("SELECT * FROM table_items WHERE session = ?").get(session);
|
|
805
|
+
return row && toItem(row);
|
|
806
|
+
}
|
|
807
|
+
function setTable(ctx, session, input) {
|
|
808
|
+
if (input.mainWorking && input.status) throw new CarnetError("TABLE_STATUS_WITH_MAIN_WORKING", "A user-action status cannot be set while main is working");
|
|
809
|
+
if (input.status !== void 0 && !isUserActionStatus(input.status)) throw new CarnetError("INVALID_STATUS", `Invalid table status "${String(input.status)}"`);
|
|
810
|
+
ctx.db.prepare(`INSERT INTO table_items (session, text, status, main_working, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
811
|
+
ON CONFLICT(session) DO UPDATE SET text = excluded.text, status = excluded.status,
|
|
812
|
+
main_working = excluded.main_working, updated_at = excluded.updated_at`).run(session, sanitizeNote(input.text), input.status ?? null, input.mainWorking ? 1 : 0, nowIso(ctx));
|
|
813
|
+
return getTable(ctx, session);
|
|
814
|
+
}
|
|
815
|
+
function clearTable(ctx, session) {
|
|
816
|
+
ctx.db.prepare("DELETE FROM table_items WHERE session = ?").run(session);
|
|
817
|
+
}
|
|
818
|
+
// src/status/view.ts
|
|
819
|
+
const BUCKET = {
|
|
820
|
+
answer: 0,
|
|
821
|
+
review: 1
|
|
822
|
+
};
|
|
823
|
+
function bucketOf(status) {
|
|
824
|
+
return BUCKET[status] ?? 2;
|
|
825
|
+
}
|
|
826
|
+
function compareWaiting(left, right) {
|
|
827
|
+
if (left.queueOrder !== null || right.queueOrder !== null) {
|
|
828
|
+
if (left.queueOrder === null) return 1;
|
|
829
|
+
if (right.queueOrder === null) return -1;
|
|
830
|
+
return left.queueOrder - right.queueOrder;
|
|
831
|
+
}
|
|
832
|
+
return bucketOf(left.status) - bucketOf(right.status) || left.startedAt.localeCompare(right.startedAt);
|
|
833
|
+
}
|
|
834
|
+
function toLine(agent) {
|
|
835
|
+
return {
|
|
836
|
+
name: agent.name,
|
|
837
|
+
status: agent.status,
|
|
838
|
+
emoji: agent.status === "closed" ? "" : EMOJI[agent.status],
|
|
839
|
+
note: agent.note
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function formatClock(date) {
|
|
843
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
844
|
+
}
|
|
845
|
+
function buildStatusView(ctx, session) {
|
|
846
|
+
const agents = listAgents(ctx, session.id);
|
|
847
|
+
const waiting = agents.filter((agent) => isUserActionStatus(agent.status)).sort(compareWaiting).map(toLine);
|
|
848
|
+
const running = agents.filter((agent) => agent.status === "running").map(toLine);
|
|
849
|
+
return {
|
|
850
|
+
time: formatClock(ctx.now()),
|
|
851
|
+
sessionName: session.name,
|
|
852
|
+
table: getTable(ctx, session.id) ?? null,
|
|
853
|
+
waiting,
|
|
854
|
+
running
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
// src/subagents.ts
|
|
858
|
+
const PAIRING_WINDOW_MILLISECONDS = 6e4;
|
|
859
|
+
const STOPPED_NOTE = "stopped without agent_done";
|
|
860
|
+
function byHostAgentId(ctx, session, hostAgentId) {
|
|
861
|
+
const row = ctx.db.prepare("SELECT * FROM agents WHERE session = ? AND host_agent_id = ?").get(session, hostAgentId);
|
|
862
|
+
return row && toAgent(row);
|
|
863
|
+
}
|
|
864
|
+
function pairSubagent(ctx, session, hostAgentId) {
|
|
865
|
+
const paired = byHostAgentId(ctx, session, hostAgentId);
|
|
866
|
+
if (paired) return paired;
|
|
867
|
+
const since = (/* @__PURE__ */ new Date(ctx.now().getTime() - PAIRING_WINDOW_MILLISECONDS)).toISOString();
|
|
868
|
+
const row = ctx.db.prepare(`SELECT * FROM agents
|
|
869
|
+
WHERE session = ? AND status = 'running' AND host_agent_id IS NULL AND started_at >= ?
|
|
870
|
+
ORDER BY started_at ASC, rowid ASC LIMIT 1`).get(session, since);
|
|
871
|
+
if (!row) return void 0;
|
|
872
|
+
ctx.db.prepare("UPDATE agents SET host_agent_id = ? WHERE session = ? AND name = ?").run(hostAgentId, session, row.name);
|
|
873
|
+
return byHostAgentId(ctx, session, hostAgentId);
|
|
874
|
+
}
|
|
875
|
+
function stopSubagent(ctx, session, hostAgentId) {
|
|
876
|
+
const agent = byHostAgentId(ctx, session, hostAgentId);
|
|
877
|
+
if (!agent || agent.status !== "running") return void 0;
|
|
878
|
+
if (agent.pendingStatus) {
|
|
879
|
+
publishPending(ctx, session, agent.name);
|
|
880
|
+
return byHostAgentId(ctx, session, hostAgentId);
|
|
881
|
+
}
|
|
882
|
+
ctx.db.prepare("UPDATE agents SET status = 'review', note = ?, updated_at = ? WHERE session = ? AND name = ?").run(STOPPED_NOTE, nowIso(ctx), session, agent.name);
|
|
883
|
+
logEvent(ctx, session, {
|
|
884
|
+
type: "finished",
|
|
885
|
+
agent: agent.name,
|
|
886
|
+
note: STOPPED_NOTE
|
|
887
|
+
});
|
|
888
|
+
return byHostAgentId(ctx, session, hostAgentId);
|
|
889
|
+
}
|
|
890
|
+
// src/carnet.ts
|
|
891
|
+
var Carnet = class {
|
|
892
|
+
group;
|
|
893
|
+
db;
|
|
894
|
+
ctx;
|
|
895
|
+
constructor(group, db, options = {}) {
|
|
896
|
+
this.group = group;
|
|
897
|
+
this.db = db;
|
|
898
|
+
this.ctx = {
|
|
899
|
+
db,
|
|
900
|
+
now: options.now ?? (() => /* @__PURE__ */ new Date())
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
sessions = {
|
|
904
|
+
get: (id) => getSession(this.ctx, id),
|
|
905
|
+
find: (reference) => findSession(this.ctx, reference),
|
|
906
|
+
require: (id) => requireSession(this.ctx, id),
|
|
907
|
+
ensure: (id, cwd) => ensureSession(this.ctx, id, cwd, this.group),
|
|
908
|
+
upsertFromHook: (payload, env) => upsertFromHook(this.ctx, this.group, payload, env),
|
|
909
|
+
touch: (id) => touchSession(this.ctx, id),
|
|
910
|
+
merge: (fromId, intoId) => mergeSessions(this.ctx, fromId, intoId),
|
|
911
|
+
purge: (keepId, days) => purgeSessions(this.ctx, keepId, days),
|
|
912
|
+
name: (id, name, purpose) => nameSession(this.ctx, id, name, purpose),
|
|
913
|
+
adoptTitle: (id, title) => adoptTitle(this.ctx, id, title),
|
|
914
|
+
list: (options) => listSessions(this.ctx, options),
|
|
915
|
+
drop: (id) => dropSession(this.ctx, id),
|
|
916
|
+
default: () => defaultSession(this.ctx),
|
|
917
|
+
byName: (name) => sessionByName(this.ctx, name),
|
|
918
|
+
byHostProcess: (host, processId) => sessionByHostProcess(this.ctx, host, processId)
|
|
919
|
+
};
|
|
920
|
+
agents = {
|
|
921
|
+
get: (session, name) => getAgent(this.ctx, session, name),
|
|
922
|
+
start: (session, input) => startAgent(this.ctx, session, input),
|
|
923
|
+
update: (session, name, input) => updateAgent(this.ctx, session, name, input),
|
|
924
|
+
finish: (session, name, input) => finishAgent(this.ctx, session, name, input),
|
|
925
|
+
drop: (session, name, reason) => dropAgent(this.ctx, session, name, reason),
|
|
926
|
+
list: (session, options) => listAgents(this.ctx, session, options),
|
|
927
|
+
reorderQueue: (session, names) => reorderQueue(this.ctx, session, names),
|
|
928
|
+
pairSubagent: (session, hostAgentId) => pairSubagent(this.ctx, session, hostAgentId),
|
|
929
|
+
stopSubagent: (session, hostAgentId) => stopSubagent(this.ctx, session, hostAgentId)
|
|
930
|
+
};
|
|
931
|
+
table = {
|
|
932
|
+
get: (session) => getTable(this.ctx, session),
|
|
933
|
+
set: (session, input) => setTable(this.ctx, session, input),
|
|
934
|
+
clear: (session) => clearTable(this.ctx, session)
|
|
935
|
+
};
|
|
936
|
+
events = {
|
|
937
|
+
log: (session, input) => logEvent(this.ctx, session, input),
|
|
938
|
+
read: (session, limit) => readEvents(this.ctx, {
|
|
939
|
+
session,
|
|
940
|
+
limit
|
|
941
|
+
})
|
|
942
|
+
};
|
|
943
|
+
status = {
|
|
944
|
+
view: (session) => buildStatusView(this.ctx, session),
|
|
945
|
+
render: (session) => renderStatus(buildStatusView(this.ctx, session)),
|
|
946
|
+
sections: (session) => renderSections(buildStatusView(this.ctx, session))
|
|
947
|
+
};
|
|
948
|
+
close() {
|
|
949
|
+
this.db.close();
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
function openCarnet(options = {}) {
|
|
953
|
+
const group = locateGroup(options);
|
|
954
|
+
return new Carnet(group, openDatabase(options.dbPath ?? group.dbPath), options);
|
|
955
|
+
}
|
|
956
|
+
// src/resume.ts
|
|
957
|
+
const RESUME_EVENTS = 20;
|
|
958
|
+
const RESUME_MAX_CHARACTERS = 2e3;
|
|
959
|
+
function formatEventLine(event) {
|
|
960
|
+
return [
|
|
961
|
+
formatClock(new Date(event.at)),
|
|
962
|
+
event.type,
|
|
963
|
+
event.agent,
|
|
964
|
+
event.link ?? event.note,
|
|
965
|
+
event.commit?.slice(0, 7)
|
|
966
|
+
].filter(Boolean).join(" ");
|
|
967
|
+
}
|
|
968
|
+
function sessionLabel(session) {
|
|
969
|
+
const name = session.name ?? session.id;
|
|
970
|
+
return session.purpose ? `${name} (${session.purpose})` : name;
|
|
971
|
+
}
|
|
972
|
+
function resumeContext(carnet, session, { events = 20, maxCharacters = RESUME_MAX_CHARACTERS } = {}) {
|
|
973
|
+
const head = `Rucher session ${sessionLabel(session)}, state when resumed:\n\n${carnet.status.render(session)}`;
|
|
974
|
+
const lines = carnet.events.read(session.id, events).map(formatEventLine);
|
|
975
|
+
const build = () => lines.length ? `${head}\n\nLast events\n${lines.map((line) => ` ${line}`).join("\n")}` : head;
|
|
976
|
+
let text = build();
|
|
977
|
+
while (text.length > maxCharacters && lines.length > 0) {
|
|
978
|
+
lines.shift();
|
|
979
|
+
text = build();
|
|
980
|
+
}
|
|
981
|
+
return text.length > maxCharacters ? `${text.slice(0, maxCharacters - 1)}…` : text;
|
|
982
|
+
}
|
|
983
|
+
export { schemaVersion as $, repoNameFor as A, hostById as B, GROUP_FILE as C, isInside as D, isGroupRoot as E, registerGroup as F, groupSettings as G, REPOS_START as H, rucherHome as I, transcriptTitle as J, replaceReposBlock as K, unregisterGroups as L, reportPathFor as M, reportsDirectoryOf as N, loadGroup as O, readRegistry as P, migrations as Q, DEFAULT_HOST as R, GROUP_DIRECTORY as S, findGroupRoot as T, claudeCode as U, REPOS_END as V, groupInstructions as W, openDatabase as X, MEMORY_DB as Y, migrate as Z, DEFAULT_PURGE_DAYS as _, sessionLabel as a, EMOJI as at, shortId as b, PAIRING_WINDOW_MILLISECONDS as c, OPEN_AGENT_STATUSES as ct, formatClock as d, isUserActionStatus as dt, PUBLISH_FALLBACK_MILLISECONDS as et, MAIN_WORKING_SUFFIX as f, EVENT_TYPES as ft, renderTableLine as g, renderStatus as h, resumeContext as i, AGENT_STATUSES as it, repoPathFor as j, locateGroup as k, STOPPED_NOTE as l, USER_ACTION_STATUSES as lt, renderSections as m, CarnetError as mt, RESUME_MAX_CHARACTERS as n, NOTE_MAX as nt, Carnet as o, LEGEND as ot, renderAgentLine as p, isEventType as pt, reposBlock as q, formatEventLine as r, sanitizeNote as rt, openCarnet as s, MAIN_WORKING_EMOJI as st, RESUME_EVENTS as t, REPORTING_NOTE as tt, buildStatusView as u, isAgentStatus as ut, purgeDays as v, REPORTS_DIRECTORY as w, DB_FILE as x, MINIMUM_ID_PREFIX as y, allHosts as z };
|
|
984
|
+
|
|
985
|
+
//# sourceMappingURL=resume-Daotqfdq.mjs.map
|