kaizen-agent 1.24.0 → 1.26.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/chatgpt.test.ts +44 -0
- package/cli/chatgpt.ts +175 -0
- package/cli/install.ts +27 -6
- package/cli/notes.test.ts +4 -4
- package/cli/pty.test.ts +133 -0
- package/cli/pty.ts +111 -0
- package/cli/settings.ts +5 -1
- package/cli/sources.test.ts +170 -25
- package/cli/sources.ts +153 -29
- package/cli/state.ts +33 -6
- package/cli/web.ts +175 -21
- 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 +17 -0
- package/skills/kaizen/discover.md +50 -0
- package/skills/kaizen/gather.md +22 -7
- package/skills/kaizen/settings.md +4 -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 +358 -103
- package/web/notebook.js +1 -1
- package/web/notes.html +16 -14
- 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;
|
|
@@ -266,7 +385,7 @@ export function sift(kind: "sheet" | "doc" | "text", body: string): { items: Ite
|
|
|
266
385
|
return { items, done };
|
|
267
386
|
}
|
|
268
387
|
|
|
269
|
-
function setLast(state: string, url: string, last: Last) {
|
|
388
|
+
export function setLast(state: string, url: string, last: Last) {
|
|
270
389
|
const list = readSources(state);
|
|
271
390
|
const src = list.find((s) => s.url === url);
|
|
272
391
|
if (!src) return;
|
|
@@ -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();
|