botholomew 0.15.4 → 0.15.6
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/package.json +1 -1
- package/src/chat/agent.ts +6 -4
- package/src/context/locks.ts +146 -0
- package/src/context/reindex.ts +10 -1
- package/src/context/store.ts +122 -90
- package/src/fs/atomic.ts +28 -4
- package/src/fs/patches.ts +39 -0
- package/src/schedules/store.ts +11 -5
- package/src/tasks/store.ts +6 -6
- package/src/tools/file/copy.ts +3 -1
- package/src/tools/file/delete.ts +1 -0
- package/src/tools/file/edit.ts +16 -11
- package/src/tools/file/move.ts +7 -2
- package/src/tools/file/write.ts +1 -1
- package/src/tools/prompt/edit.ts +148 -0
- package/src/tools/prompt/read.ts +70 -0
- package/src/tools/registry.ts +9 -4
- package/src/tools/schedule/edit.ts +126 -0
- package/src/tools/skill/edit.ts +3 -33
- package/src/tools/task/edit.ts +214 -0
- package/src/tools/tool.ts +9 -0
- package/src/tui/App.tsx +95 -12
- package/src/worker/heartbeat.ts +20 -0
- package/src/worker/llm.ts +4 -0
- package/src/worker/tick.ts +6 -1
- package/src/tools/context/update-beliefs.ts +0 -64
- package/src/tools/context/update-goals.ts +0 -64
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
atomicWriteIfUnchanged,
|
|
4
|
+
MtimeConflictError,
|
|
5
|
+
readWithMtime,
|
|
6
|
+
} from "../../fs/atomic.ts";
|
|
7
|
+
import { applyLinePatches, LinePatchSchema } from "../../fs/patches.ts";
|
|
8
|
+
import type { TaskFrontmatter } from "../../tasks/schema.ts";
|
|
9
|
+
import {
|
|
10
|
+
CircularDependencyError,
|
|
11
|
+
parseTaskFile,
|
|
12
|
+
serializeTask,
|
|
13
|
+
taskFilePath,
|
|
14
|
+
validateBlockedBy,
|
|
15
|
+
} from "../../tasks/store.ts";
|
|
16
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
17
|
+
|
|
18
|
+
const inputSchema = z.object({
|
|
19
|
+
id: z.string().describe("Task id"),
|
|
20
|
+
patches: z.array(LinePatchSchema).describe("Patches to apply"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const outputSchema = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
path: z.string().nullable(),
|
|
26
|
+
applied: z.number(),
|
|
27
|
+
content: z.string(),
|
|
28
|
+
is_error: z.boolean(),
|
|
29
|
+
error_type: z.string().optional(),
|
|
30
|
+
message: z.string().optional(),
|
|
31
|
+
next_action_hint: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const taskEditTool = {
|
|
35
|
+
name: "task_edit",
|
|
36
|
+
description:
|
|
37
|
+
"[[ bash equivalent command: patch ]] Apply git-style line-range patches to a task file. Operates on the whole file (frontmatter + body). Only pending tasks may be edited. Patches that fail validation or introduce a circular dependency are rejected without writing. Re-serializes to canonicalize YAML and bump updated_at.",
|
|
38
|
+
group: "task",
|
|
39
|
+
inputSchema,
|
|
40
|
+
outputSchema,
|
|
41
|
+
execute: async (input, ctx) => {
|
|
42
|
+
const filePath = taskFilePath(ctx.projectDir, input.id);
|
|
43
|
+
const file = await readWithMtime(filePath);
|
|
44
|
+
if (!file) {
|
|
45
|
+
return {
|
|
46
|
+
id: input.id,
|
|
47
|
+
path: null,
|
|
48
|
+
applied: 0,
|
|
49
|
+
content: "",
|
|
50
|
+
is_error: true,
|
|
51
|
+
error_type: "not_found",
|
|
52
|
+
message: `Task not found: ${input.id}`,
|
|
53
|
+
next_action_hint:
|
|
54
|
+
"Use list_tasks to see available tasks, or create_task to make one.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const original = file.content;
|
|
59
|
+
const preParsed = parseTaskFile(original, file.mtimeMs);
|
|
60
|
+
if (!preParsed.ok) {
|
|
61
|
+
return {
|
|
62
|
+
id: input.id,
|
|
63
|
+
path: filePath,
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: original,
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "invalid_task",
|
|
68
|
+
message: `Existing task file is malformed: ${preParsed.reason}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (preParsed.task.status !== "pending") {
|
|
72
|
+
return {
|
|
73
|
+
id: input.id,
|
|
74
|
+
path: filePath,
|
|
75
|
+
applied: 0,
|
|
76
|
+
content: original,
|
|
77
|
+
is_error: true,
|
|
78
|
+
error_type: "not_pending",
|
|
79
|
+
message: `Cannot edit task ${input.id}: only pending tasks can be edited (current status: ${preParsed.task.status})`,
|
|
80
|
+
next_action_hint:
|
|
81
|
+
"Use complete_task / fail_task / wait_task for terminal status changes.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const updated = applyLinePatches(original, input.patches);
|
|
86
|
+
const parsed = parseTaskFile(updated, file.mtimeMs);
|
|
87
|
+
if (!parsed.ok) {
|
|
88
|
+
return {
|
|
89
|
+
id: input.id,
|
|
90
|
+
path: filePath,
|
|
91
|
+
applied: 0,
|
|
92
|
+
content: original,
|
|
93
|
+
is_error: true,
|
|
94
|
+
error_type: "invalid_task",
|
|
95
|
+
message: `Patched content failed validation: ${parsed.reason}`,
|
|
96
|
+
next_action_hint:
|
|
97
|
+
"Check that frontmatter YAML stays valid and required fields (id, name, status, priority, etc.) are preserved.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (parsed.task.id !== input.id) {
|
|
101
|
+
return {
|
|
102
|
+
id: input.id,
|
|
103
|
+
path: filePath,
|
|
104
|
+
applied: 0,
|
|
105
|
+
content: original,
|
|
106
|
+
is_error: true,
|
|
107
|
+
error_type: "id_mismatch",
|
|
108
|
+
message: `frontmatter id '${parsed.task.id}' does not match the task id '${input.id}'`,
|
|
109
|
+
next_action_hint:
|
|
110
|
+
"Don't change the id frontmatter field; create a new task with create_task if you need a different id.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (parsed.task.status !== "pending") {
|
|
114
|
+
return {
|
|
115
|
+
id: input.id,
|
|
116
|
+
path: filePath,
|
|
117
|
+
applied: 0,
|
|
118
|
+
content: original,
|
|
119
|
+
is_error: true,
|
|
120
|
+
error_type: "status_change_forbidden",
|
|
121
|
+
message: `Patch would change task status from 'pending' to '${parsed.task.status}'. Status transitions must go through complete_task / fail_task / wait_task so the terminal-tool loop and summary are recorded.`,
|
|
122
|
+
next_action_hint:
|
|
123
|
+
"Don't edit the status frontmatter field; use complete_task, fail_task, or wait_task to transition state.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Worker-managed fields: claim state is set by claimNextTask /
|
|
127
|
+
// releaseTaskLock, output by complete_task, waiting_reason by wait_task.
|
|
128
|
+
// A pending task should have all four null; refuse any patch that
|
|
129
|
+
// changes them so the agent can't backdoor a claim or fake an output.
|
|
130
|
+
const workerManaged: Array<keyof typeof parsed.task> = [
|
|
131
|
+
"claimed_by",
|
|
132
|
+
"claimed_at",
|
|
133
|
+
"output",
|
|
134
|
+
"waiting_reason",
|
|
135
|
+
];
|
|
136
|
+
for (const field of workerManaged) {
|
|
137
|
+
if (parsed.task[field] !== preParsed.task[field]) {
|
|
138
|
+
return {
|
|
139
|
+
id: input.id,
|
|
140
|
+
path: filePath,
|
|
141
|
+
applied: 0,
|
|
142
|
+
content: original,
|
|
143
|
+
is_error: true,
|
|
144
|
+
error_type: "worker_field_change_forbidden",
|
|
145
|
+
message: `Patch would change worker-managed field '${field}'. Only complete_task / fail_task / wait_task / the claim loop may set claimed_by, claimed_at, output, and waiting_reason.`,
|
|
146
|
+
next_action_hint: `Don't edit the ${field} frontmatter field.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await validateBlockedBy(ctx.projectDir, input.id, parsed.task.blocked_by);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof CircularDependencyError) {
|
|
155
|
+
return {
|
|
156
|
+
id: input.id,
|
|
157
|
+
path: filePath,
|
|
158
|
+
applied: 0,
|
|
159
|
+
content: original,
|
|
160
|
+
is_error: true,
|
|
161
|
+
error_type: "circular_dependency",
|
|
162
|
+
message: err.message,
|
|
163
|
+
next_action_hint:
|
|
164
|
+
"Pick blockers that don't transitively depend on this task.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fm: TaskFrontmatter = {
|
|
171
|
+
id: parsed.task.id,
|
|
172
|
+
name: parsed.task.name,
|
|
173
|
+
description: parsed.task.description,
|
|
174
|
+
priority: parsed.task.priority,
|
|
175
|
+
status: parsed.task.status,
|
|
176
|
+
blocked_by: parsed.task.blocked_by,
|
|
177
|
+
context_paths: parsed.task.context_paths,
|
|
178
|
+
output: parsed.task.output,
|
|
179
|
+
waiting_reason: parsed.task.waiting_reason,
|
|
180
|
+
claimed_by: parsed.task.claimed_by,
|
|
181
|
+
claimed_at: parsed.task.claimed_at,
|
|
182
|
+
created_at: parsed.task.created_at,
|
|
183
|
+
updated_at: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
const serialized = serializeTask(fm, parsed.task.body);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await atomicWriteIfUnchanged(filePath, serialized, file.mtimeMs);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof MtimeConflictError) {
|
|
191
|
+
return {
|
|
192
|
+
id: input.id,
|
|
193
|
+
path: filePath,
|
|
194
|
+
applied: 0,
|
|
195
|
+
content: original,
|
|
196
|
+
is_error: true,
|
|
197
|
+
error_type: "mtime_conflict",
|
|
198
|
+
message: `Task was modified concurrently: ${err.message}`,
|
|
199
|
+
next_action_hint:
|
|
200
|
+
"Re-read the task with view_task and recompute your patch line numbers before retrying.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: input.id,
|
|
208
|
+
path: filePath,
|
|
209
|
+
applied: input.patches.length,
|
|
210
|
+
content: serialized,
|
|
211
|
+
is_error: false,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
package/src/tools/tool.ts
CHANGED
|
@@ -17,6 +17,15 @@ export interface ToolContext {
|
|
|
17
17
|
projectDir: string;
|
|
18
18
|
config: Required<BotholomewConfig>;
|
|
19
19
|
mcpxClient: McpxClient | null;
|
|
20
|
+
/**
|
|
21
|
+
* Identifier of the agent process running this tool, used as the holder
|
|
22
|
+
* id for per-path context locks (`src/context/locks.ts`) so the worker
|
|
23
|
+
* reaper can identify and release locks abandoned by a crashed worker.
|
|
24
|
+
* Workers pass their `workerId`; chat sessions pass a `chat:` prefixed
|
|
25
|
+
* id; tests and one-off CLI calls leave it `undefined` (the store falls
|
|
26
|
+
* back to `pid:<n>`).
|
|
27
|
+
*/
|
|
28
|
+
workerId?: string;
|
|
20
29
|
/**
|
|
21
30
|
* Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
|
|
22
31
|
* Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
|
package/src/tui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
1
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
abortActiveStream,
|
|
@@ -174,9 +174,33 @@ function AppInner({
|
|
|
174
174
|
initialPrompt,
|
|
175
175
|
}: AppInnerProps) {
|
|
176
176
|
const { exit } = useApp();
|
|
177
|
+
const { stdout } = useStdout();
|
|
177
178
|
const { markActivity } = useIdle();
|
|
179
|
+
// Pin the root box to a known viewport height so the rendered frame size
|
|
180
|
+
// never crosses the viewport boundary. Ink 7's renderer wipes scrollback
|
|
181
|
+
// (`shouldClearTerminalForFrame` → `ansiEscapes.clearTerminal`) whenever
|
|
182
|
+
// the dynamic frame transitions in/out of fullscreen, so a fluctuating
|
|
183
|
+
// `outputHeight` (streaming text + tool boxes appearing/disappearing) used
|
|
184
|
+
// to delete the chat history on every turn. Ink doesn't pin a height on
|
|
185
|
+
// its internal root, so a `height="100%"` on our root collapses to `auto`
|
|
186
|
+
// — we have to pass the explicit row count and re-read it on resize.
|
|
187
|
+
const [rows, setRows] = useState(stdout?.rows ?? 24);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!stdout) return;
|
|
190
|
+
const onResize = () => setRows(stdout.rows ?? 24);
|
|
191
|
+
stdout.on("resize", onResize);
|
|
192
|
+
return () => {
|
|
193
|
+
stdout.off("resize", onResize);
|
|
194
|
+
};
|
|
195
|
+
}, [stdout]);
|
|
178
196
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
179
197
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
198
|
+
// `clearing` gates new submissions while /clear's async work is in flight.
|
|
199
|
+
// Without it, a message submitted during the clearChatSession await runs
|
|
200
|
+
// sendMessage against the OLD thread id, then the IIFE's setMessages([sys])
|
|
201
|
+
// overwrites the user bubble it added — the message disappears.
|
|
202
|
+
const [clearing, setClearing] = useState(false);
|
|
203
|
+
const clearingRef = useRef(false);
|
|
180
204
|
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
181
205
|
const [inputValue, setInputValue] = useState("");
|
|
182
206
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
@@ -192,6 +216,7 @@ function AppInner({
|
|
|
192
216
|
const [splashDone, setSplashDone] = useState(skipSplash);
|
|
193
217
|
const [error, setError] = useState<string | null>(null);
|
|
194
218
|
const sessionRef = useRef<ChatSession | null>(null);
|
|
219
|
+
const shuttingDownRef = useRef(false);
|
|
195
220
|
const [activeTab, setActiveTab] = useState<TabId>(1);
|
|
196
221
|
const [workerRunning, setWorkerRunning] = useState(false);
|
|
197
222
|
const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
|
|
@@ -251,16 +276,52 @@ function AppInner({
|
|
|
251
276
|
|
|
252
277
|
return () => {
|
|
253
278
|
cancelled = true;
|
|
279
|
+
// Fire-and-forget safety net: only triggers when unmount happens via a
|
|
280
|
+
// path that didn't go through performShutdown (which nulls sessionRef
|
|
281
|
+
// first). React doesn't await unmount cleanups, so the goodbye lands
|
|
282
|
+
// before mcpx finishes closing — that's fine for non-Ctrl-C paths.
|
|
254
283
|
if (sessionRef.current) {
|
|
255
|
-
const
|
|
256
|
-
|
|
284
|
+
const session = sessionRef.current;
|
|
285
|
+
const threadId = session.threadId;
|
|
286
|
+
abortActiveStream(session);
|
|
287
|
+
void endChatSession(session);
|
|
257
288
|
process.stderr.write(
|
|
258
|
-
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\n`,
|
|
289
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
259
290
|
);
|
|
260
291
|
}
|
|
261
292
|
};
|
|
262
293
|
}, [projectDir, resumeThreadId]);
|
|
263
294
|
|
|
295
|
+
const performShutdown = useCallback(async () => {
|
|
296
|
+
if (shuttingDownRef.current) {
|
|
297
|
+
// Second Ctrl-C while cleanup is in flight — give the user an escape
|
|
298
|
+
// hatch. 130 = standard SIGINT exit code.
|
|
299
|
+
process.exit(130);
|
|
300
|
+
}
|
|
301
|
+
shuttingDownRef.current = true;
|
|
302
|
+
|
|
303
|
+
const session = sessionRef.current;
|
|
304
|
+
// Null the ref so the useEffect cleanup that runs on Ink unmount becomes
|
|
305
|
+
// a no-op — otherwise it would double-print the goodbye and double-close
|
|
306
|
+
// the mcpx client.
|
|
307
|
+
sessionRef.current = null;
|
|
308
|
+
|
|
309
|
+
if (session) {
|
|
310
|
+
const threadId = session.threadId;
|
|
311
|
+
abortActiveStream(session);
|
|
312
|
+
try {
|
|
313
|
+
await endChatSession(session);
|
|
314
|
+
} catch {
|
|
315
|
+
// Best-effort: the user pressed Ctrl-C, surfacing a stack trace here
|
|
316
|
+
// would just hide the goodbye line.
|
|
317
|
+
}
|
|
318
|
+
process.stderr.write(
|
|
319
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
exit();
|
|
323
|
+
}, [exit]);
|
|
324
|
+
|
|
264
325
|
// Minimum splash screen duration
|
|
265
326
|
useEffect(() => {
|
|
266
327
|
const timer = setTimeout(() => setSplashDone(true), 2000);
|
|
@@ -309,9 +370,12 @@ function AppInner({
|
|
|
309
370
|
(input: string, key: any) => {
|
|
310
371
|
markActivityRef.current();
|
|
311
372
|
|
|
312
|
-
// Ctrl+C exits
|
|
373
|
+
// Ctrl+C exits. Routed through performShutdown so the in-flight LLM
|
|
374
|
+
// stream is aborted and mcpx is closed before we unmount Ink — without
|
|
375
|
+
// that, one Ctrl-C prints the goodbye but the process stays pinned by
|
|
376
|
+
// the open HTTPS socket and a second Ctrl-C is needed.
|
|
313
377
|
if (input === "c" && key.ctrl) {
|
|
314
|
-
|
|
378
|
+
void performShutdown();
|
|
315
379
|
return;
|
|
316
380
|
}
|
|
317
381
|
|
|
@@ -393,7 +457,7 @@ function AppInner({
|
|
|
393
457
|
}
|
|
394
458
|
}
|
|
395
459
|
},
|
|
396
|
-
[
|
|
460
|
+
[performShutdown, syncQueue],
|
|
397
461
|
);
|
|
398
462
|
|
|
399
463
|
useInput(stableAppHandler);
|
|
@@ -586,6 +650,8 @@ function AppInner({
|
|
|
586
650
|
async (text: string) => {
|
|
587
651
|
const trimmed = text.trim();
|
|
588
652
|
if (!trimmed || !sessionRef.current) return;
|
|
653
|
+
// /clear is mid-flight: don't queue against the old thread id.
|
|
654
|
+
if (clearingRef.current) return;
|
|
589
655
|
|
|
590
656
|
setInputValue("");
|
|
591
657
|
|
|
@@ -643,7 +709,7 @@ function AppInner({
|
|
|
643
709
|
syncQueue();
|
|
644
710
|
processQueue();
|
|
645
711
|
},
|
|
646
|
-
exit,
|
|
712
|
+
exit: () => void performShutdown(),
|
|
647
713
|
clearChat: () => {
|
|
648
714
|
const session = sessionRef.current;
|
|
649
715
|
if (!session) return;
|
|
@@ -656,6 +722,12 @@ function AppInner({
|
|
|
656
722
|
// poll below immediately rather than waiting on the
|
|
657
723
|
// createThread/endThread round trip first.
|
|
658
724
|
abortActiveStream(session);
|
|
725
|
+
// Block new submissions until the new thread id is in place —
|
|
726
|
+
// otherwise the user's first post-/clear message races the
|
|
727
|
+
// async createThread, runs against the old thread id, and is
|
|
728
|
+
// then wiped by setMessages([sys]) below.
|
|
729
|
+
clearingRef.current = true;
|
|
730
|
+
setClearing(true);
|
|
659
731
|
void (async () => {
|
|
660
732
|
// Wait for any in-flight processQueue iteration to finish so
|
|
661
733
|
// its trailing `finalizeSegment` can't race our state reset
|
|
@@ -696,6 +768,9 @@ function AppInner({
|
|
|
696
768
|
timestamp: new Date(),
|
|
697
769
|
},
|
|
698
770
|
]);
|
|
771
|
+
} finally {
|
|
772
|
+
clearingRef.current = false;
|
|
773
|
+
setClearing(false);
|
|
699
774
|
}
|
|
700
775
|
})();
|
|
701
776
|
},
|
|
@@ -708,7 +783,7 @@ function AppInner({
|
|
|
708
783
|
syncQueue();
|
|
709
784
|
processQueue();
|
|
710
785
|
},
|
|
711
|
-
[
|
|
786
|
+
[performShutdown, processQueue, syncQueue],
|
|
712
787
|
);
|
|
713
788
|
|
|
714
789
|
const sessionDbPath = sessionRef.current?.dbPath;
|
|
@@ -774,7 +849,7 @@ function AppInner({
|
|
|
774
849
|
const threadId = sessionRef.current.threadId;
|
|
775
850
|
|
|
776
851
|
return (
|
|
777
|
-
<Box flexDirection="column" height="
|
|
852
|
+
<Box flexDirection="column" height={rows} overflow="hidden">
|
|
778
853
|
{/* Completed messages — rendered once to terminal scrollback.
|
|
779
854
|
Must live outside the display="none" tab wrappers so the <Static>
|
|
780
855
|
node always has proper terminal width in its Yoga layout.
|
|
@@ -786,11 +861,19 @@ function AppInner({
|
|
|
786
861
|
|
|
787
862
|
{/* Tab content area — all panels stay mounted to avoid expensive
|
|
788
863
|
remount cycles. display="none" hides inactive panels from
|
|
789
|
-
layout without destroying them.
|
|
864
|
+
layout without destroying them.
|
|
865
|
+
The chat tab's flexGrow box is overflow-clipped so streaming
|
|
866
|
+
content can't push the rendered frame past the viewport — see
|
|
867
|
+
the comment on the `rows` state for why that matters.
|
|
868
|
+
`justifyContent="flex-end"` keeps active streaming content + the
|
|
869
|
+
tool-call card pinned to the bottom of the chat area (just
|
|
870
|
+
above the input bar) instead of leaving a tall gap below them. */}
|
|
790
871
|
<Box
|
|
791
872
|
display={activeTab === 1 ? "flex" : "none"}
|
|
792
873
|
flexDirection="column"
|
|
793
874
|
flexGrow={1}
|
|
875
|
+
overflow="hidden"
|
|
876
|
+
justifyContent="flex-end"
|
|
794
877
|
>
|
|
795
878
|
<MessageList
|
|
796
879
|
streamingText={streamingText}
|
|
@@ -871,7 +954,7 @@ function AppInner({
|
|
|
871
954
|
value={inputValue}
|
|
872
955
|
onChange={setInputValue}
|
|
873
956
|
onSubmit={handleSubmit}
|
|
874
|
-
disabled={activeTab !== 1}
|
|
957
|
+
disabled={activeTab !== 1 || clearing}
|
|
875
958
|
history={inputHistory}
|
|
876
959
|
header={inputBarHeader}
|
|
877
960
|
slashCommands={slashCommands}
|
package/src/worker/heartbeat.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reapOrphanContextLocks } from "../context/locks.ts";
|
|
1
2
|
import { reapOrphanScheduleLocks } from "../schedules/store.ts";
|
|
2
3
|
import { reapOrphanLocks as reapOrphanTaskLocks } from "../tasks/store.ts";
|
|
3
4
|
import { logger } from "../utils/logger.ts";
|
|
@@ -81,6 +82,25 @@ export function startReaper(
|
|
|
81
82
|
logger.warn(`schedule lock reap failed: ${err}`);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
try {
|
|
86
|
+
// Context locks store either a `workerId` (worker holders) or a
|
|
87
|
+
// free-form id like `chat` / `pid:<n>` (chat sessions, CLI). Only
|
|
88
|
+
// expire holders that look like worker ids; conservatively treat
|
|
89
|
+
// any other holder as alive — we don't manage the chat session's
|
|
90
|
+
// lifecycle here.
|
|
91
|
+
const released = await reapOrphanContextLocks(projectDir, async (id) => {
|
|
92
|
+
if (id.startsWith("pid:") || id.startsWith("chat")) return true;
|
|
93
|
+
return await isAlive(id);
|
|
94
|
+
});
|
|
95
|
+
if (released.length > 0) {
|
|
96
|
+
logger.warn(
|
|
97
|
+
`released ${released.length} orphan context lock(s): ${released.join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.warn(`context lock reap failed: ${err}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
try {
|
|
85
105
|
const pruned = await pruneStoppedWorkers(
|
|
86
106
|
projectDir,
|
package/src/worker/llm.ts
CHANGED
|
@@ -53,6 +53,7 @@ export async function runAgentLoop(input: {
|
|
|
53
53
|
dbPath: string;
|
|
54
54
|
threadId: string;
|
|
55
55
|
projectDir: string;
|
|
56
|
+
workerId?: string;
|
|
56
57
|
mcpxClient?: McpxClient | null;
|
|
57
58
|
callbacks?: WorkerStreamCallbacks;
|
|
58
59
|
}): Promise<AgentLoopResult> {
|
|
@@ -63,6 +64,7 @@ export async function runAgentLoop(input: {
|
|
|
63
64
|
dbPath,
|
|
64
65
|
threadId,
|
|
65
66
|
projectDir,
|
|
67
|
+
workerId,
|
|
66
68
|
callbacks,
|
|
67
69
|
} = input;
|
|
68
70
|
|
|
@@ -207,6 +209,7 @@ export async function runAgentLoop(input: {
|
|
|
207
209
|
projectDir,
|
|
208
210
|
config,
|
|
209
211
|
mcpxClient: input.mcpxClient ?? null,
|
|
212
|
+
workerId,
|
|
210
213
|
});
|
|
211
214
|
const elapsed = Date.now() - start;
|
|
212
215
|
callbacks?.onToolEnd(
|
|
@@ -265,6 +268,7 @@ interface ToolCallCtx {
|
|
|
265
268
|
projectDir: string;
|
|
266
269
|
config: Required<BotholomewConfig>;
|
|
267
270
|
mcpxClient: McpxClient | null;
|
|
271
|
+
workerId?: string;
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
async function executeToolCall(
|
package/src/worker/tick.ts
CHANGED
|
@@ -77,6 +77,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
77
77
|
projectDir,
|
|
78
78
|
dbPath,
|
|
79
79
|
config,
|
|
80
|
+
workerId,
|
|
80
81
|
mcpxClient,
|
|
81
82
|
callbacks,
|
|
82
83
|
task,
|
|
@@ -115,6 +116,7 @@ export async function runSpecificTask(opts: {
|
|
|
115
116
|
projectDir: opts.projectDir,
|
|
116
117
|
dbPath: opts.dbPath,
|
|
117
118
|
config: opts.config,
|
|
119
|
+
workerId: opts.workerId,
|
|
118
120
|
mcpxClient: opts.mcpxClient,
|
|
119
121
|
callbacks: opts.callbacks,
|
|
120
122
|
task,
|
|
@@ -126,11 +128,13 @@ async function runClaimedTask(opts: {
|
|
|
126
128
|
projectDir: string;
|
|
127
129
|
dbPath: string;
|
|
128
130
|
config: Required<BotholomewConfig>;
|
|
131
|
+
workerId: string;
|
|
129
132
|
mcpxClient?: McpxClient | null;
|
|
130
133
|
callbacks?: WorkerStreamCallbacks;
|
|
131
134
|
task: Task;
|
|
132
135
|
}): Promise<void> {
|
|
133
|
-
const { projectDir, dbPath, config, mcpxClient, callbacks, task } =
|
|
136
|
+
const { projectDir, dbPath, config, workerId, mcpxClient, callbacks, task } =
|
|
137
|
+
opts;
|
|
134
138
|
|
|
135
139
|
logger.info(`Claimed task: ${task.name} (${task.id})`);
|
|
136
140
|
if (!callbacks && task.description) {
|
|
@@ -161,6 +165,7 @@ async function runClaimedTask(opts: {
|
|
|
161
165
|
dbPath,
|
|
162
166
|
threadId,
|
|
163
167
|
projectDir,
|
|
168
|
+
workerId,
|
|
164
169
|
mcpxClient,
|
|
165
170
|
callbacks,
|
|
166
171
|
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
-
import {
|
|
5
|
-
type ContextFileMeta,
|
|
6
|
-
parseContextFile,
|
|
7
|
-
serializeContextFile,
|
|
8
|
-
} from "../../utils/frontmatter.ts";
|
|
9
|
-
import type { ToolDefinition } from "../tool.ts";
|
|
10
|
-
|
|
11
|
-
const inputSchema = z.object({
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(
|
|
15
|
-
"The new beliefs content (replaces existing body, frontmatter is preserved)",
|
|
16
|
-
),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const outputSchema = z.object({
|
|
20
|
-
message: z.string(),
|
|
21
|
-
path: z.string(),
|
|
22
|
-
is_error: z.boolean(),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
export const updateBeliefsTool = {
|
|
26
|
-
name: "update_beliefs",
|
|
27
|
-
description:
|
|
28
|
-
"Update the agent's beliefs file (prompts/beliefs.md). Preserves frontmatter, replaces content body.",
|
|
29
|
-
group: "context",
|
|
30
|
-
inputSchema,
|
|
31
|
-
outputSchema,
|
|
32
|
-
execute: async (input, ctx) => {
|
|
33
|
-
const filePath = join(getPromptsDir(ctx.projectDir), "beliefs.md");
|
|
34
|
-
const file = Bun.file(filePath);
|
|
35
|
-
|
|
36
|
-
let meta: ContextFileMeta = {
|
|
37
|
-
loading: "always",
|
|
38
|
-
"agent-modification": true,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
if (await file.exists()) {
|
|
42
|
-
const raw = await file.text();
|
|
43
|
-
const parsed = parseContextFile(raw);
|
|
44
|
-
meta = parsed.meta;
|
|
45
|
-
|
|
46
|
-
if (!meta["agent-modification"]) {
|
|
47
|
-
return {
|
|
48
|
-
message: "Agent modification not allowed for this file",
|
|
49
|
-
path: filePath,
|
|
50
|
-
is_error: true,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const serialized = serializeContextFile(meta, input.content);
|
|
56
|
-
await Bun.write(filePath, serialized);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
message: "Updated beliefs.md",
|
|
60
|
-
path: filePath,
|
|
61
|
-
is_error: false,
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { getPromptsDir } from "../../constants.ts";
|
|
4
|
-
import {
|
|
5
|
-
type ContextFileMeta,
|
|
6
|
-
parseContextFile,
|
|
7
|
-
serializeContextFile,
|
|
8
|
-
} from "../../utils/frontmatter.ts";
|
|
9
|
-
import type { ToolDefinition } from "../tool.ts";
|
|
10
|
-
|
|
11
|
-
const inputSchema = z.object({
|
|
12
|
-
content: z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(
|
|
15
|
-
"The new goals content (replaces existing body, frontmatter is preserved)",
|
|
16
|
-
),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const outputSchema = z.object({
|
|
20
|
-
message: z.string(),
|
|
21
|
-
path: z.string(),
|
|
22
|
-
is_error: z.boolean(),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
export const updateGoalsTool = {
|
|
26
|
-
name: "update_goals",
|
|
27
|
-
description:
|
|
28
|
-
"Update the agent's goals file (prompts/goals.md). Preserves frontmatter, replaces content body.",
|
|
29
|
-
group: "context",
|
|
30
|
-
inputSchema,
|
|
31
|
-
outputSchema,
|
|
32
|
-
execute: async (input, ctx) => {
|
|
33
|
-
const filePath = join(getPromptsDir(ctx.projectDir), "goals.md");
|
|
34
|
-
const file = Bun.file(filePath);
|
|
35
|
-
|
|
36
|
-
let meta: ContextFileMeta = {
|
|
37
|
-
loading: "always",
|
|
38
|
-
"agent-modification": true,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
if (await file.exists()) {
|
|
42
|
-
const raw = await file.text();
|
|
43
|
-
const parsed = parseContextFile(raw);
|
|
44
|
-
meta = parsed.meta;
|
|
45
|
-
|
|
46
|
-
if (!meta["agent-modification"]) {
|
|
47
|
-
return {
|
|
48
|
-
message: "Agent modification not allowed for this file",
|
|
49
|
-
path: filePath,
|
|
50
|
-
is_error: true,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const serialized = serializeContextFile(meta, input.content);
|
|
56
|
-
await Bun.write(filePath, serialized);
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
message: "Updated goals.md",
|
|
60
|
-
path: filePath,
|
|
61
|
-
is_error: false,
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|