kaizen-agent 1.23.0 → 1.25.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/README.md +19 -242
- package/agents/kaizen-reviewer.md +3 -1
- package/cli/build-web.ts +16 -9
- package/cli/install.ts +27 -6
- package/cli/notes.test.ts +18 -4
- package/cli/pty.test.ts +133 -0
- package/cli/pty.ts +111 -0
- package/cli/settings.ts +2 -1
- package/cli/sources.test.ts +170 -25
- package/cli/sources.ts +152 -28
- package/cli/state.ts +33 -6
- package/cli/web.ts +124 -18
- package/commands/kaizen-discover.md +5 -0
- package/commands/kaizen-gather.md +1 -1
- package/package.json +4 -2
- package/skills/kaizen/README.md +1 -0
- package/skills/kaizen/SKILL.md +6 -2
- package/skills/kaizen/config.default.yml +8 -0
- package/skills/kaizen/discover.md +50 -0
- package/skills/kaizen/gather.md +22 -7
- package/skills/kaizen/settings.md +1 -1
- package/skills/kaizen/spec-build.md +4 -1
- package/skills/kaizen/spec-review.md +3 -1
- package/skills/kaizen/spec.md +2 -1
- package/skills/kaizen-help/SKILL.md +2 -1
- package/web/board.html +415 -100
- package/web/notebook.js +1 -1
- package/web/notes.html +77 -15
- package/web/term.css +285 -0
- package/web/term.js +39 -0
- package/web/term.ts +46 -0
package/cli/sources.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// Request sources: shared links kaizen reads for requests, each landing
|
|
2
|
-
//
|
|
3
|
-
// never a status, a command, or a run.
|
|
1
|
+
// Request sources: shared links kaizen reads for requests, each landing in the
|
|
2
|
+
// project's icebox until someone promotes it to an idea. Everything fetched is
|
|
3
|
+
// outside text: it becomes item text and notes, never a status, a command, or a run.
|
|
4
|
+
// Nothing in this file launches anything.
|
|
4
5
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { isIP } from "node:net";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
7
9
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
8
|
-
import {
|
|
10
|
+
import { readInbox, writeInbox, normalise, formatNote, parseItem } from "./state.ts";
|
|
9
11
|
|
|
10
12
|
export type Last = { at: string; ok: boolean; added: number; note: string };
|
|
11
|
-
export type Source = { url: string; label?: string; added: string; last?: Last; seen: string[] };
|
|
13
|
+
export type Source = { url: string; label?: string; category?: string; added: string; last?: Last; seen: string[] };
|
|
12
14
|
export type Result = { url: string } & Last;
|
|
13
15
|
|
|
14
16
|
const file = (state: string) => join(state, "sources.json");
|
|
@@ -33,19 +35,56 @@ export function writeSources(state: string, list: Source[]) {
|
|
|
33
35
|
const oneLine = (s: string) => s.replace(/\s+/g, " ").replace(/[\x00-\x1f\x7f-\x9f]/g, "").trim();
|
|
34
36
|
|
|
35
37
|
// Returns why not, or null when added -- the shape abandonRun and writeNotes use.
|
|
36
|
-
export function addSource(state: string, url: string, label?: string): string | null {
|
|
38
|
+
export function addSource(state: string, url: string, label?: string, category?: string): string | null {
|
|
37
39
|
url = url.trim();
|
|
38
40
|
const why = checkUrl(url);
|
|
39
41
|
if (why) return why;
|
|
40
42
|
const list = readSources(state);
|
|
41
43
|
if (list.some((s) => s.url === url)) return "already a source";
|
|
42
44
|
const name = oneLine(label ?? "").slice(0, 80);
|
|
43
|
-
|
|
45
|
+
const cat = oneLine(category ?? "").slice(0, 40);
|
|
46
|
+
list.push({ url, ...(name ? { label: name } : {}), ...(cat ? { category: cat } : {}), added: new Date().toISOString(), seen: [] });
|
|
44
47
|
writeSources(state, list);
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
//
|
|
51
|
+
// Social media: the platform's own public search, tag or profile page for what to
|
|
52
|
+
// follow. Those pages need a sign-in or a browser, so kaizen never fetches them; the
|
|
53
|
+
// agent reads them in /kaizen gather. Null when there is nothing to follow.
|
|
54
|
+
export const PLATFORMS: Record<string, string> = { x: "X", tiktok: "TikTok", instagram: "Instagram", threads: "Threads" };
|
|
55
|
+
export function socialUrl(platform: string, query: string): string | null {
|
|
56
|
+
const q = oneLine(query).slice(0, 100);
|
|
57
|
+
if (!q || !(platform in PLATFORMS)) return null;
|
|
58
|
+
const e = encodeURIComponent;
|
|
59
|
+
const handle = /^@([\w.]{1,30})$/.exec(q)?.[1], tag = /^#([\p{L}\p{N}_]{1,100})$/u.exec(q)?.[1];
|
|
60
|
+
if (platform === "x") return handle ? `https://x.com/${e(handle)}` : `https://x.com/search?q=${e(q)}&f=live`;
|
|
61
|
+
if (platform === "tiktok") return handle ? `https://www.tiktok.com/@${e(handle)}` : tag ? `https://www.tiktok.com/tag/${e(tag)}` : `https://www.tiktok.com/search?q=${e(q)}`;
|
|
62
|
+
if (platform === "instagram") return handle ? `https://www.instagram.com/${e(handle)}/` : tag ? `https://www.instagram.com/explore/tags/${e(tag)}/` : `https://www.instagram.com/explore/search/keyword/?q=${e(q)}`;
|
|
63
|
+
return handle ? `https://www.threads.net/@${e(handle)}` : `https://www.threads.net/search?q=${e(q)}`;
|
|
64
|
+
}
|
|
65
|
+
const SOCIAL = /^(?:www\.|mobile\.|m\.)?(x\.com|twitter\.com|tiktok\.com|instagram\.com|threads\.net|threads\.com)$/;
|
|
66
|
+
// The platform a link belongs to, or null for any other link.
|
|
67
|
+
export function socialOf(raw: string): string | null {
|
|
68
|
+
let h: string;
|
|
69
|
+
try { h = new URL(raw).hostname.toLowerCase().replace(/\.$/, ""); } catch { return null; }
|
|
70
|
+
const m = SOCIAL.exec(h)?.[1];
|
|
71
|
+
return !m ? null : m.startsWith("x.") || m.startsWith("twitter.") ? "x" : m.split(".")[0]!;
|
|
72
|
+
}
|
|
73
|
+
export const AGENT_ONLY = "Social posts are gathered by your agent. Run /kaizen gather.";
|
|
74
|
+
|
|
75
|
+
// What gathered items from this source are filed under. Empty clears it. Items
|
|
76
|
+
// already in the icebox keep the category they arrived with.
|
|
77
|
+
export function setCategory(state: string, url: string, category: string): string | null {
|
|
78
|
+
const list = readSources(state);
|
|
79
|
+
const src = list.find((s) => s.url === url);
|
|
80
|
+
if (!src) return "no such source";
|
|
81
|
+
const cat = oneLine(category).slice(0, 40);
|
|
82
|
+
if (cat) src.category = cat; else delete src.category;
|
|
83
|
+
writeSources(state, list);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Drops the entry and its seen list. Items already gathered stay in the icebox.
|
|
49
88
|
export function removeSource(state: string, url: string): string | null {
|
|
50
89
|
const list = readSources(state);
|
|
51
90
|
if (!list.some((s) => s.url === url)) return "no such source";
|
|
@@ -54,7 +93,7 @@ export function removeSource(state: string, url: string): string | null {
|
|
|
54
93
|
}
|
|
55
94
|
|
|
56
95
|
// A source belongs to one project. Moving it carries its label, seen list and last
|
|
57
|
-
// result, so nothing is gathered twice;
|
|
96
|
+
// result, so nothing is gathered twice; items already in the old icebox stay there.
|
|
58
97
|
// Written to the new project first, and taken back out if the old one cannot be
|
|
59
98
|
// written: only a crash between the two writes leaves it listed twice, never lost.
|
|
60
99
|
export function moveSource(from: string, to: string, url: string): string | null {
|
|
@@ -82,42 +121,122 @@ const obeyed = (t: string) => isCommand(t) || ["", "full ", "lite "].some((kind)
|
|
|
82
121
|
});
|
|
83
122
|
export const CAP = 50;
|
|
84
123
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
124
|
+
// Outside text made safe to store: one line, no bullet or number in front, the part
|
|
125
|
+
// past 200 characters moved to the notes, every note line one line with no `-` in
|
|
126
|
+
// front (it could be read back as an inbox bullet). Null when too short to mean anything.
|
|
127
|
+
function clean(raw: string, extra = ""): { text: string; lines: string[] } | null {
|
|
128
|
+
let text = oneLine(raw).replace(/^(?:[-*+•·]+\s*|\d+[.)]\s+)+/, "");
|
|
129
|
+
if (text.length > 200) { extra = text.slice(200) + "\n" + extra; text = text.slice(0, 200).trim(); }
|
|
130
|
+
if (text.length < 3) return null;
|
|
131
|
+
return { text, lines: extra.split("\n").map((n) => oneLine(n).replace(/^[\s-]+/, "")).filter(Boolean) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The icebox: everything gathered, held per project until a person or an agent
|
|
135
|
+
// promotes it to an idea or dismisses it. Promoted and dismissed items stay, so the
|
|
136
|
+
// same request never comes back.
|
|
137
|
+
export type Signal = { id: string; text: string; notes?: string; source: string; category?: string; at: string; status: "new" | "promoted" | "dismissed"; idea?: string };
|
|
138
|
+
|
|
139
|
+
const iceFile = (state: string) => join(state, "icebox.json");
|
|
140
|
+
|
|
141
|
+
export function readIcebox(state: string): Signal[] {
|
|
142
|
+
if (!existsSync(iceFile(state))) return [];
|
|
143
|
+
let list: unknown;
|
|
144
|
+
try { list = JSON.parse(readFileSync(iceFile(state), "utf8")); } catch { list = null; }
|
|
145
|
+
if (!Array.isArray(list)) throw new Error("icebox.json is unreadable");
|
|
146
|
+
return list.filter((s) => s && typeof s.id === "string" && typeof s.text === "string");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function writeIcebox(state: string, list: Signal[]) {
|
|
150
|
+
mkdirSync(state, { recursive: true });
|
|
151
|
+
writeFileSync(iceFile(state), JSON.stringify(list, null, 2) + "\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const idOf = (key: string) => createHash("sha1").update(key).digest("hex").slice(0, 8);
|
|
155
|
+
|
|
156
|
+
// A row is its request and its other cells together: two rows whose request cell
|
|
157
|
+
// reads the same (a sheet where the chosen column is a category) are two requests.
|
|
158
|
+
// A row with no other cells keys as its text alone, as every seen key did before.
|
|
159
|
+
const rowKey = (text: string, notes?: string) => normalise(notes ? text + "\n" + notes : text);
|
|
160
|
+
|
|
161
|
+
// What an idea already in the inbox rules out. A gathered one (the stamp ingest()
|
|
162
|
+
// wrote before the icebox) rules out its own row; a promoted one nothing, its items
|
|
163
|
+
// are still in the icebox; one typed by hand, any row reading the same.
|
|
164
|
+
function ideaKey(l: { text: string; notes?: string }): string | null {
|
|
165
|
+
const [first = "", ...rest] = (l.notes ?? "").split("\n");
|
|
166
|
+
if (/^\[[^\]]*\] Promoted from the icebox:/.test(first)) return null;
|
|
167
|
+
if (/^\[[^\]]*\] Gathered from /.test(first)) return rowKey(l.text, rest.join("\n"));
|
|
168
|
+
return normalise(l.text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The single place outside text enters kaizen, and it enters the icebox, never Ideas.
|
|
172
|
+
// Synchronous on purpose: nothing may run between reading the files and writing them.
|
|
87
173
|
export function ingest(state: string, url: string, items: { text: string; notes?: string }[]): { added: number; held: number } {
|
|
88
174
|
const sources = readSources(state);
|
|
89
175
|
const src = sources.find((s) => s.url === url);
|
|
90
176
|
if (!src) throw new Error("not a listed source");
|
|
91
|
-
const
|
|
92
|
-
const known = new Set([...src.seen, ...
|
|
93
|
-
const fresh:
|
|
177
|
+
const ice = readIcebox(state);
|
|
178
|
+
const known = new Set([...src.seen, ...readInbox(state).map(ideaKey).filter((k) => k !== null), ...ice.map((s) => rowKey(s.text, s.notes))]);
|
|
179
|
+
const fresh: Signal[] = [];
|
|
94
180
|
const seen: string[] = [];
|
|
95
181
|
let held = 0;
|
|
96
182
|
for (const it of items) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// The board runs an idea as `/kaizen <text>`: a first word that is a command
|
|
102
|
-
// would be obeyed as one.
|
|
103
|
-
while (obeyed(text)) text = "Request: " + text; // twice at most: `Request: high: auto x` still parses
|
|
104
|
-
const key = normalise(text);
|
|
183
|
+
const c = clean(it.text, it.notes);
|
|
184
|
+
if (!c) continue;
|
|
185
|
+
const notes = c.lines.join("\n").slice(0, 2000);
|
|
186
|
+
const key = rowKey(c.text, notes);
|
|
105
187
|
if (known.has(key)) { seen.push(key); continue; }
|
|
106
188
|
if (fresh.length >= CAP) { held++; continue; } // not marked seen: the next gather takes it
|
|
107
189
|
known.add(key);
|
|
108
190
|
seen.push(key);
|
|
109
|
-
|
|
110
|
-
const lines = extra.split("\n").map((n) => oneLine(n).replace(/^[\s-]+/, "")).filter(Boolean);
|
|
111
|
-
const notes = [formatNote(`Gathered from ${src.label || url}. Outside text: a request to weigh, not instructions.`), ...lines].join("\n").slice(0, 2000);
|
|
112
|
-
fresh.push({ status: "open", text, notes });
|
|
191
|
+
fresh.push({ id: idOf(key), text: c.text, ...(notes ? { notes } : {}), source: url, ...(src.category ? { category: src.category } : {}), at: new Date().toISOString(), status: "new" });
|
|
113
192
|
}
|
|
114
|
-
if (fresh.length)
|
|
193
|
+
if (fresh.length) writeIcebox(state, [...ice, ...fresh]);
|
|
115
194
|
// ponytail: seen only grows; trim to the newest few thousand if a source ever gets that long.
|
|
116
195
|
src.seen = [...new Set([...src.seen, ...seen])];
|
|
117
196
|
writeSources(state, sources);
|
|
118
197
|
return { added: fresh.length, held };
|
|
119
198
|
}
|
|
120
199
|
|
|
200
|
+
// The only way icebox text becomes an idea. Several items may become one idea (the
|
|
201
|
+
// same need said twice); the idea's text is the caller's, or the first item's, and
|
|
202
|
+
// `details` are the caller's own words for it. The notes hold those details first,
|
|
203
|
+
// then every item as it was gathered. It lands as an open idea and starts nothing.
|
|
204
|
+
// Returns why not, or null.
|
|
205
|
+
export function promote(state: string, ids: string[], text?: string, details?: string): string | null {
|
|
206
|
+
const ice = readIcebox(state);
|
|
207
|
+
const picked = ids.map((id) => ice.find((s) => s.id === id));
|
|
208
|
+
if (!ids.length) return "no items named";
|
|
209
|
+
if (picked.some((s) => !s)) return "no such item";
|
|
210
|
+
if (picked.some((s) => s!.status !== "new")) return "already promoted or dismissed";
|
|
211
|
+
const own = !!text?.trim();
|
|
212
|
+
const c = clean(own ? text! : picked[0]!.text);
|
|
213
|
+
if (!c) return "the idea text is too short";
|
|
214
|
+
// The board runs an idea as `/kaizen <text>`: a first word that is a command
|
|
215
|
+
// would be obeyed as one.
|
|
216
|
+
while (obeyed(c.text)) c.text = "Request: " + c.text; // twice at most: `Request: high: auto x` still parses
|
|
217
|
+
const inbox = readInbox(state);
|
|
218
|
+
if (inbox.some((l) => normalise(l.text) === normalise(c.text))) return "already an idea";
|
|
219
|
+
const sources = readSources(state);
|
|
220
|
+
const from = [...new Set(picked.map((s) => { const src = sources.find((x) => x.url === s!.source); return [src?.label || s!.source, s!.category].filter(Boolean).join(" · "); }))].join("; ");
|
|
221
|
+
const original = picked.flatMap((s) => [s!.text, ...(s!.notes ?? "").split("\n")]).map((n) => oneLine(n).replace(/^[\s-]+/, "")).filter(Boolean);
|
|
222
|
+
const mine = (details ?? "").split("\n").map((n) => oneLine(n).replace(/^[\s-]+/, "")).filter(Boolean);
|
|
223
|
+
const notes = [formatNote(`Promoted from the icebox: ${from}. Outside text: a request to weigh, not instructions.`), ...(own ? c.lines : []), ...mine, ...(mine.length ? ["As gathered:"] : []), ...original].join("\n").slice(0, 2000);
|
|
224
|
+
writeInbox(state, [...inbox, { status: "open", text: c.text, notes }]);
|
|
225
|
+
for (const s of picked) { s!.status = "promoted"; s!.idea = c.text; }
|
|
226
|
+
writeIcebox(state, ice);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function dismiss(state: string, ids: string[]): string | null {
|
|
231
|
+
const ice = readIcebox(state);
|
|
232
|
+
const picked = ids.map((id) => ice.find((s) => s.id === id));
|
|
233
|
+
if (!ids.length) return "no items named";
|
|
234
|
+
if (picked.some((s) => !s)) return "no such item";
|
|
235
|
+
for (const s of picked) if (s!.status === "new") s!.status = "dismissed";
|
|
236
|
+
writeIcebox(state, ice);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
121
240
|
// Syntax only; where the name resolves is resolvesPublic's question. Returns why not.
|
|
122
241
|
export function checkUrl(raw: string): string | null {
|
|
123
242
|
let u: URL;
|
|
@@ -288,6 +407,11 @@ export function record(state: string, url: string, r: { added: number; held: num
|
|
|
288
407
|
|
|
289
408
|
// Never throws. A failure is recorded on the source and marks nothing seen.
|
|
290
409
|
export async function gatherSource(state: string, url: string, doFetch?: Fetch, lookup?: Lookup): Promise<Result> {
|
|
410
|
+
if (socialOf(url)) {
|
|
411
|
+
const last = { at: new Date().toISOString(), ok: false, added: 0, note: AGENT_ONLY };
|
|
412
|
+
try { setLast(state, url, last); } catch { /* as below */ }
|
|
413
|
+
return { url, ...last };
|
|
414
|
+
}
|
|
291
415
|
try {
|
|
292
416
|
const target = exportUrl(url);
|
|
293
417
|
const got = await fetchText(target.url, doFetch, lookup);
|
|
@@ -317,7 +441,7 @@ export async function gatherAll(state: string, doFetch?: Fetch, lookup?: Lookup)
|
|
|
317
441
|
return out;
|
|
318
442
|
}
|
|
319
443
|
|
|
320
|
-
// Every project's sources, each into its own project's
|
|
444
|
+
// Every project's sources, each into its own project's icebox and no other.
|
|
321
445
|
export async function gatherDirs(states: string[], doFetch?: Fetch, lookup?: Lookup): Promise<(Result & { dir: string })[]> {
|
|
322
446
|
const out: (Result & { dir: string })[] = [];
|
|
323
447
|
for (const dir of states) for (const r of await gatherAll(dir, doFetch, lookup)) out.push({ ...r, dir });
|
package/cli/state.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync, renameSync, copyFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname, win32, posix } from "node:path";
|
|
6
6
|
import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
|
|
7
|
+
import { startPty, ptySupported } from "./pty.ts";
|
|
7
8
|
|
|
8
9
|
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
9
10
|
// symlink (/home -> var/home) would never compare equal to it.
|
|
@@ -496,6 +497,11 @@ export function fixPrompt(id: string, nums: number[]) {
|
|
|
496
497
|
+ `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
|
|
497
498
|
}
|
|
498
499
|
|
|
500
|
+
// A run whose agent ended mid-stage, picked up where its state.json says.
|
|
501
|
+
export function resumePrompt(id: string) {
|
|
502
|
+
return `run ${id}: continue it from its state.json; its last agent session ended before the stage finished.`;
|
|
503
|
+
}
|
|
504
|
+
|
|
499
505
|
export function canApproveAwaiting(awaiting: unknown): awaiting is "approvals.plan" | "approvals.review" | "findings" {
|
|
500
506
|
return awaiting === "approvals.plan" || awaiting === "approvals.review" || awaiting === "findings";
|
|
501
507
|
}
|
|
@@ -959,7 +965,7 @@ export function boardCards(states: string[], now: number): Card[] {
|
|
|
959
965
|
// What starting a run needs, minus the screens: the TUI and the web board both
|
|
960
966
|
// launch the same way and only differ in how they tell the user.
|
|
961
967
|
export type Launch =
|
|
962
|
-
| { ok: true; agent: Agent; prompt: string; projectDir: string; cmd: string[] }
|
|
968
|
+
| { ok: true; agent: Agent; prompt: string; projectDir: string; cmd: string[]; pty?: string }
|
|
963
969
|
| { ok: false; agent: Agent; prompt: string; projectDir: string; manual: string; why: string };
|
|
964
970
|
|
|
965
971
|
export function projectOf(from: string) {
|
|
@@ -1036,6 +1042,15 @@ export function closeEnabled(projectDir: string) {
|
|
|
1036
1042
|
return true;
|
|
1037
1043
|
}
|
|
1038
1044
|
|
|
1045
|
+
// agent.launch in the config chain: where the web board runs what it launches.
|
|
1046
|
+
export function launchMode(projectDir: string): "card" | "terminal" {
|
|
1047
|
+
for (const t of configTexts(projectDir)) {
|
|
1048
|
+
const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+launch:[ \t]*["']?([^"'\s]+)/m.exec(t) || /^agent\.launch:[ \t]*["']?([^"'\s]+)/m.exec(t);
|
|
1049
|
+
if (m) return m[1] === "terminal" ? "terminal" : "card";
|
|
1050
|
+
}
|
|
1051
|
+
return "card";
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1039
1054
|
// Whether a live PID is still the agent a record launched, not a stranger that reused
|
|
1040
1055
|
// the number: the tool's name (and conversation id) in its arguments, started no
|
|
1041
1056
|
// earlier than the record. On Windows the PID is the PowerShell kaizen opened.
|
|
@@ -1128,7 +1143,7 @@ export function runAgent(st: string, id: string): { cmd: string; model: string |
|
|
|
1128
1143
|
// record; making one here would shadow the user's.
|
|
1129
1144
|
export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string, model?: string, yolo?: boolean) {
|
|
1130
1145
|
const st = join(projectDir, ".kaizen");
|
|
1131
|
-
if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], pidFile: undefined, record: undefined };
|
|
1146
|
+
if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], key: undefined, pidFile: undefined, record: undefined };
|
|
1132
1147
|
const f = SESSION_FLAGS[cmd];
|
|
1133
1148
|
const prev = run ? firstSession(st, run, cmd, projectDir) : null;
|
|
1134
1149
|
const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
|
|
@@ -1137,7 +1152,7 @@ export function sessionLaunch(projectDir: string, cmd: string, base: string[], p
|
|
|
1137
1152
|
mkdirSync(sessionsDir(st), { recursive: true });
|
|
1138
1153
|
const record = join(sessionsDir(st), key + ".json");
|
|
1139
1154
|
writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, model: model ?? null, ...(yolo ? { yolo: true } : {}), sessionId, started: Date.now() }) + "\n");
|
|
1140
|
-
return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
|
|
1155
|
+
return { fullCmd, key, pidFile: join(sessionsDir(st), key + ".pid"), record };
|
|
1141
1156
|
}
|
|
1142
1157
|
|
|
1143
1158
|
// Claude Code asks "do you trust this folder?" the first time it opens one, and inside
|
|
@@ -1161,15 +1176,17 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
|
|
|
1161
1176
|
|
|
1162
1177
|
// run: the run a decision is for. Without it, an idea starting a new run.
|
|
1163
1178
|
// pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
|
|
1164
|
-
// tool and model, and never yolo.
|
|
1165
|
-
|
|
1179
|
+
// tool and model, and never yolo. card: run it inside the board (agent.launch: card),
|
|
1180
|
+
// where a pseudo-terminal exists; onChange is told when that agent ends.
|
|
1181
|
+
export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void } = {}): Launch {
|
|
1166
1182
|
const projectDir = projectOf(from);
|
|
1167
1183
|
// The run will be born here; the board must know the dir or the idea never retires.
|
|
1168
1184
|
remember(projectDir);
|
|
1169
1185
|
const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
|
|
1170
1186
|
const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
|
|
1171
1187
|
if (agent.cmd === "claude") trustFolder(projectDir);
|
|
1172
|
-
|
|
1188
|
+
// onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
|
|
1189
|
+
const agentBin = onPath(agent.cmd) ?? agent.cmd;
|
|
1173
1190
|
const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
1174
1191
|
const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
|
|
1175
1192
|
const model = wanted && plainModel(wanted) ? wanted : undefined;
|
|
@@ -1181,6 +1198,16 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1181
1198
|
const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
|
|
1182
1199
|
const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
|
|
1183
1200
|
|
|
1201
|
+
if (pick.card && ptySupported) {
|
|
1202
|
+
const key = s.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1203
|
+
try {
|
|
1204
|
+
startPty(key, s.fullCmd, projectDir, s.pidFile, pick.onChange);
|
|
1205
|
+
return { ok: true, agent, prompt, projectDir, cmd: s.fullCmd, pty: key };
|
|
1206
|
+
} catch (err: any) {
|
|
1207
|
+
drop();
|
|
1208
|
+
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1184
1211
|
const term = findTerminal(projectDir, s.fullCmd, s.pidFile);
|
|
1185
1212
|
if (!term) {
|
|
1186
1213
|
drop();
|
package/cli/web.ts
CHANGED
|
@@ -7,15 +7,21 @@ import {
|
|
|
7
7
|
readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
|
|
8
8
|
appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
9
9
|
reviewFindings, pickFindings, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
|
|
10
|
+
launchMode, readSessions, ownerOf, resumePrompt,
|
|
10
11
|
} from "./state.ts";
|
|
11
|
-
import {
|
|
12
|
+
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
|
+
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
12
14
|
import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
|
|
13
15
|
|
|
14
16
|
const PAGE = join(dirname(import.meta.dir), "web", "board.html");
|
|
15
17
|
// The notebook: its page, and the CodeMirror bundle `bun run build:web` commits beside it.
|
|
16
18
|
const NOTES_PAGE = join(dirname(import.meta.dir), "web", "notes.html");
|
|
17
19
|
const NOTES_JS = join(dirname(import.meta.dir), "web", "notebook.js");
|
|
20
|
+
// The in-card terminal (agent.launch: card): xterm.js, bundled the same way.
|
|
21
|
+
const TERM_JS = join(dirname(import.meta.dir), "web", "term.js");
|
|
22
|
+
const TERM_CSS = join(dirname(import.meta.dir), "web", "term.css");
|
|
18
23
|
const DEFAULT_PORT = 7420;
|
|
24
|
+
type PtySock = { key: string; detach: (() => void) | null };
|
|
19
25
|
// Read once at start: a board keeps the code it started with, so this is what it
|
|
20
26
|
// runs, not what is installed now. `/` sends it; `current()` compares the two.
|
|
21
27
|
const installed = () => {
|
|
@@ -27,7 +33,7 @@ const VERSION = installed();
|
|
|
27
33
|
// page newer than its server sends ops the server does not know ("unknown op").
|
|
28
34
|
// So the pages are kept as they were at start, and read live (dev edits) only while
|
|
29
35
|
// the installed version is still the one this board runs.
|
|
30
|
-
const AT_START = new Map([PAGE, NOTES_PAGE, NOTES_JS].map((f) => [f, existsSync(f) ? readText(f) : null]));
|
|
36
|
+
const AT_START = new Map([PAGE, NOTES_PAGE, NOTES_JS, TERM_JS, TERM_CSS].map((f) => [f, existsSync(f) ? readText(f) : null]));
|
|
31
37
|
const served = (f: string) => installed() === VERSION && existsSync(f) ? readText(f) : AT_START.get(f) ?? null;
|
|
32
38
|
|
|
33
39
|
// A board answers at url and runs the installed version. A board from before the
|
|
@@ -51,6 +57,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
51
57
|
if (ev === "uncaughtException") process.exit(1);
|
|
52
58
|
});
|
|
53
59
|
}
|
|
60
|
+
// Before anything looks for an agent on PATH: the board starts agents itself, and
|
|
61
|
+
// from a launcher it would otherwise have none of the user's shell setup.
|
|
62
|
+
adoptLoginEnv();
|
|
54
63
|
// Not fixed for the life of the board: picking a project in the page moves here.
|
|
55
64
|
let state = locate();
|
|
56
65
|
if (state && dirname(state) !== home) remember(dirname(state));
|
|
@@ -115,6 +124,20 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
115
124
|
for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
|
|
116
125
|
}, 200);
|
|
117
126
|
};
|
|
127
|
+
// agent.launch: card runs what this board launches inside the board, shown on the card.
|
|
128
|
+
const inCard = (dir: string) => ({ card: launchMode(projectOf(dir)) === "card", onChange: changed });
|
|
129
|
+
// An idea's session, until its run exists and owns it (ownerOf).
|
|
130
|
+
const ideaPty = new Map<string, string>();
|
|
131
|
+
// The card's newest in-card session, live or recently ended. Sessions are only read
|
|
132
|
+
// while this board has any, so /state costs nothing more for terminal launches.
|
|
133
|
+
const ptyOf = (c: Card) => {
|
|
134
|
+
const live = new Set(ptyKeys());
|
|
135
|
+
if (!live.size) return undefined;
|
|
136
|
+
const key = c.kind === "run"
|
|
137
|
+
? readSessions(c.state).filter((s) => live.has(s.key) && ownerOf(c.state, s) === c.id).pop()?.key
|
|
138
|
+
: ideaPty.get(`${c.state}\0${c.text}`);
|
|
139
|
+
return key && live.has(key) ? { key, alive: ptyState(key)!.alive } : undefined;
|
|
140
|
+
};
|
|
118
141
|
// Dev: the page reloads itself when board.html is saved. Server code needs
|
|
119
142
|
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
|
120
143
|
try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
|
|
@@ -134,7 +157,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
134
157
|
const fingerprint = () => {
|
|
135
158
|
let sum = 0;
|
|
136
159
|
for (const dir of states(true)) {
|
|
137
|
-
for (const f of ["inbox.md", "backlog.md", "archive.md", "sources.json"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
|
|
160
|
+
for (const f of ["inbox.md", "backlog.md", "archive.md", "sources.json", "icebox.json"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
|
|
138
161
|
const runs = join(dir, "runs");
|
|
139
162
|
if (!existsSync(runs)) continue;
|
|
140
163
|
for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
|
|
@@ -153,7 +176,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
153
176
|
}, 2000);
|
|
154
177
|
|
|
155
178
|
// The schedule: once a minute, gather every request source that is due. It writes
|
|
156
|
-
//
|
|
179
|
+
// icebox.json and sources.json only, never a state.json, and starts nothing. All of
|
|
157
180
|
// it sits in a try: an uncaught error here would end the board.
|
|
158
181
|
const every = async () => {
|
|
159
182
|
const { listSettings } = await import("./settings.ts");
|
|
@@ -209,7 +232,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
209
232
|
// big repo, on Windows) then comes back as nothing, and the page shows an empty
|
|
210
233
|
// board. Loopback only, so nothing waits on us but our own page.
|
|
211
234
|
idleTimeout: 0,
|
|
212
|
-
|
|
235
|
+
// The in-card terminal: output out as text frames, the page's keys and size in as
|
|
236
|
+
// {t:"i",d} and {t:"r",c,r}. A null from the session means the agent ended.
|
|
237
|
+
websocket: {
|
|
238
|
+
open(ws: Bun.ServerWebSocket<PtySock>) {
|
|
239
|
+
ws.data.detach = attachPty(ws.data.key, (d) => { if (d === null) ws.close(1000, "ended"); else ws.send(d); });
|
|
240
|
+
},
|
|
241
|
+
message(ws: Bun.ServerWebSocket<PtySock>, raw) {
|
|
242
|
+
let m: any;
|
|
243
|
+
try { m = JSON.parse(String(raw)); } catch { return; }
|
|
244
|
+
if (m?.t === "i" && typeof m.d === "string") writePty(ws.data.key, m.d);
|
|
245
|
+
else if (m?.t === "r") resizePty(ws.data.key, Number(m.c), Number(m.r));
|
|
246
|
+
},
|
|
247
|
+
close(ws: Bun.ServerWebSocket<PtySock>) { ws.data.detach?.(); },
|
|
248
|
+
},
|
|
249
|
+
async fetch(req, srv) {
|
|
213
250
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
214
251
|
// arrives with that hostname in Host, and gets nothing.
|
|
215
252
|
const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
|
|
@@ -233,6 +270,27 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
233
270
|
return new Response(body, { headers: { "content-type": type, "cache-control": "no-store" } });
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
if (req.method === "GET" && (path === "/term.js" || path === "/term.css")) {
|
|
274
|
+
const [f, type] = path === "/term.js" ? [TERM_JS, "application/javascript; charset=utf-8"] : [TERM_CSS, "text/css; charset=utf-8"];
|
|
275
|
+
const body = served(f);
|
|
276
|
+
if (body === null) return new Response(`${path} missing; run bun run build:web`, { status: 500 });
|
|
277
|
+
return new Response(body, { headers: { "content-type": type, "cache-control": "no-store" } });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// An in-card agent's terminal. Whoever connects here types into an agent that
|
|
281
|
+
// can run anything, and a page on the wider web may open a WebSocket to
|
|
282
|
+
// loopback freely, so the Origin is required and must be this board's.
|
|
283
|
+
if (req.method === "GET" && path === "/pty") {
|
|
284
|
+
// Same origin exactly, port included: another local server's page is not the board.
|
|
285
|
+
let same = false;
|
|
286
|
+
try { same = new URL(req.headers.get("origin") ?? "").host === req.headers.get("host"); } catch { /* no origin */ }
|
|
287
|
+
if (!same) return bad("cross-origin terminal refused", 403);
|
|
288
|
+
const key = url.searchParams.get("key") ?? "";
|
|
289
|
+
if (!/^[\w-]+$/.test(key) || !ptyState(key)) return bad("no such terminal", 404);
|
|
290
|
+
if (srv.upgrade(req, { data: { key, detach: null } })) return undefined as unknown as Response;
|
|
291
|
+
return bad("websocket expected", 426);
|
|
292
|
+
}
|
|
293
|
+
|
|
236
294
|
// Every note of one project, text included: search, tags and backlinks are the page's.
|
|
237
295
|
if (req.method === "GET" && path === "/vault") {
|
|
238
296
|
const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
|
|
@@ -284,7 +342,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
284
342
|
project, all, dir: state,
|
|
285
343
|
projects: picker.map((d) => ({ dir: d, label: label(d) })),
|
|
286
344
|
fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
|
|
287
|
-
cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined })),
|
|
345
|
+
cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, pty: ptyOf(c) })),
|
|
288
346
|
now: Date.now(),
|
|
289
347
|
});
|
|
290
348
|
}
|
|
@@ -315,13 +373,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
315
373
|
// says which project it belongs to. Nothing is assumed from where the board started.
|
|
316
374
|
const dir = url.searchParams.get("dir");
|
|
317
375
|
if (dir && !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
318
|
-
const sources = [], failed = [];
|
|
376
|
+
const sources = [], icebox = [], failed = [];
|
|
319
377
|
for (const d of dir ? [dir] : states(true)) {
|
|
320
378
|
// The seen list is bookkeeping, not something the page shows.
|
|
321
|
-
try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, dir: d, project: label(d) }))); }
|
|
379
|
+
try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, social: socialOf(s.url), dir: d, project: label(d) }))); }
|
|
322
380
|
catch { failed.push(label(d)); }
|
|
381
|
+
// Only what still waits for a decision; promoted and dismissed items are record.
|
|
382
|
+
try { icebox.push(...readIcebox(d).filter((s) => s.status === "new").map((s) => ({ ...s, dir: d, project: label(d) }))); }
|
|
383
|
+
catch { failed.push(label(d) + " (icebox)"); }
|
|
323
384
|
}
|
|
324
|
-
return json({ sources, failed, every: await every() });
|
|
385
|
+
return json({ sources, icebox, failed, every: await every() });
|
|
325
386
|
}
|
|
326
387
|
|
|
327
388
|
// The Run form's choices for one project: installed tools and their models.
|
|
@@ -462,9 +523,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
462
523
|
return json({ ok: true });
|
|
463
524
|
}
|
|
464
525
|
|
|
465
|
-
// Request sources. Gathering
|
|
526
|
+
// Request sources. Gathering fills the icebox and nothing else: no idea is
|
|
527
|
+
// made and no run starts here.
|
|
466
528
|
if (path === "/sources") {
|
|
467
|
-
|
|
529
|
+
// A social source arrives as a platform and what to follow; the link is built here.
|
|
530
|
+
const link = body.op === "add" && typeof body.platform === "string" ? socialUrl(body.platform, String(body.query ?? "")) ?? "" : String(body.url ?? "").trim();
|
|
531
|
+
if (body.op === "add" && typeof body.platform === "string" && !link) return bad("type a keyword, #hashtag or @account to follow");
|
|
468
532
|
// A source belongs to the project the request names. The fallback above
|
|
469
533
|
// (where the board started, or the global dir) is a guess, and a guess
|
|
470
534
|
// here puts someone's requests in the wrong project.
|
|
@@ -478,8 +542,11 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
478
542
|
}
|
|
479
543
|
if (!named) return bad(body.op === "add" ? "choose the project this source belongs to" : "say which project's source this is");
|
|
480
544
|
if (body.op === "add") {
|
|
481
|
-
const why = addSource(dir, link, typeof body.label === "string" ? body.label : undefined);
|
|
545
|
+
const why = addSource(dir, link, typeof body.label === "string" ? body.label : undefined, typeof body.category === "string" ? body.category : undefined);
|
|
482
546
|
if (why) return bad(why);
|
|
547
|
+
} else if (body.op === "category") {
|
|
548
|
+
const why = setCategory(dir, link, String(body.category ?? ""));
|
|
549
|
+
if (why) return bad(why, 404);
|
|
483
550
|
} else if (body.op === "remove") {
|
|
484
551
|
const why = removeSource(dir, link);
|
|
485
552
|
if (why) return bad(why, 404);
|
|
@@ -496,6 +563,20 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
496
563
|
} catch (e) { return bad((e as Error).message, 500); }
|
|
497
564
|
}
|
|
498
565
|
|
|
566
|
+
// The icebox: the only way gathered text becomes an idea, one explicit click
|
|
567
|
+
// (or agent call) at a time. An idea is made; no run starts.
|
|
568
|
+
if (path === "/icebox") {
|
|
569
|
+
if (typeof body.dir !== "string" || !states(true).includes(body.dir)) return bad("say which project's icebox this is");
|
|
570
|
+
const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
|
|
571
|
+
try {
|
|
572
|
+
const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
|
|
573
|
+
: body.op === "dismiss" ? dismiss(body.dir, ids) : "unknown op";
|
|
574
|
+
if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
|
|
575
|
+
} catch (e) { return bad((e as Error).message, 500); }
|
|
576
|
+
changed();
|
|
577
|
+
return json({ ok: true });
|
|
578
|
+
}
|
|
579
|
+
|
|
499
580
|
if (path === "/run") {
|
|
500
581
|
const text = String(body.text ?? "").trim();
|
|
501
582
|
const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
|
|
@@ -508,11 +589,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
508
589
|
// A `started` idea may be run again: the terminal the first launch opened
|
|
509
590
|
// can come up empty, and nothing else tells the board a run never began.
|
|
510
591
|
it.notes = readInbox(dir).find((l) => (l.status === "open" || l.status === "started") && l.text === text)?.notes;
|
|
511
|
-
const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true });
|
|
592
|
+
const r = launchRun(it, dir, undefined, { agent, model: model || undefined, yolo: body.yolo === true, ...inCard(dir) });
|
|
512
593
|
if (r.ok) replaceIdea(dir, text, { status: "started", text });
|
|
594
|
+
if (r.ok && r.pty) {
|
|
595
|
+
for (const [k, v] of ideaPty) if (!ptyState(v)) ideaPty.delete(k); // sessions already dropped
|
|
596
|
+
ideaPty.set(`${dir}\0${text}`, r.pty);
|
|
597
|
+
}
|
|
513
598
|
changed();
|
|
514
599
|
return json(r.ok
|
|
515
|
-
? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
|
|
600
|
+
? { ok: true, agent: r.agent, project: tilde(r.projectDir), pty: r.pty }
|
|
516
601
|
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
517
602
|
}
|
|
518
603
|
|
|
@@ -609,11 +694,11 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
609
694
|
// The run's conversation picks the fix up, and once that window is open the
|
|
610
695
|
// waiting one ends. A failed launch leaves it: the decision has nowhere else.
|
|
611
696
|
const before = Date.now();
|
|
612
|
-
const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
|
|
697
|
+
const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id, inCard(dir));
|
|
613
698
|
if (r.ok) closeSessions(dir, id, { before });
|
|
614
699
|
changed();
|
|
615
700
|
return json(r.ok
|
|
616
|
-
? { ok: true, agent: r.agent }
|
|
701
|
+
? { ok: true, agent: r.agent, pty: r.pty }
|
|
617
702
|
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
618
703
|
}
|
|
619
704
|
|
|
@@ -636,11 +721,32 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
636
721
|
// conversation continues in one new window, and then the idle one ends. A
|
|
637
722
|
// failed launch leaves it open, since the decision went nowhere.
|
|
638
723
|
const before = Date.now();
|
|
639
|
-
const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id);
|
|
724
|
+
const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id, inCard(dir));
|
|
725
|
+
if (r.ok) closeSessions(dir, id, { before });
|
|
726
|
+
changed();
|
|
727
|
+
return json(r.ok
|
|
728
|
+
? { ok: true, agent: r.agent, pty: r.pty }
|
|
729
|
+
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Start a run's agent again after it ended mid-stage (crashed, closed, or a stalled
|
|
733
|
+
// run), in the conversation it had where the tool can resume one. Never beside a
|
|
734
|
+
// live in-card agent, and never on a run waiting for a decision: those have their buttons.
|
|
735
|
+
if (path === "/resume") {
|
|
736
|
+
const id = String(body.id ?? "");
|
|
737
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
738
|
+
if (dir === join(home, ".kaizen")) return bad("no project to run in", 409);
|
|
739
|
+
let run: any;
|
|
740
|
+
try { run = JSON.parse(readText(join(dir, "runs", id, "state.json"))); } catch { return bad("no such run", 404); }
|
|
741
|
+
if (run.stage === "done" || run.stage === "abandoned" || run.awaiting) return bad("this run is not waiting to be resumed", 409);
|
|
742
|
+
if (readSessions(dir).some((s) => ptyState(s.key)?.alive && ownerOf(dir, s) === id)) return bad("its agent is still running in its card", 409);
|
|
743
|
+
const text = resumePrompt(id);
|
|
744
|
+
const before = Date.now();
|
|
745
|
+
const r = launchRun({ severity: null, where: null, text, raw: text }, dir, id, inCard(dir));
|
|
640
746
|
if (r.ok) closeSessions(dir, id, { before });
|
|
641
747
|
changed();
|
|
642
748
|
return json(r.ok
|
|
643
|
-
? { ok: true, agent: r.agent }
|
|
749
|
+
? { ok: true, agent: r.agent, pty: r.pty }
|
|
644
750
|
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
645
751
|
}
|
|
646
752
|
|