botholomew 0.12.5 → 0.13.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.
Files changed (103) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/utils/title.ts +5 -7
  80. package/src/utils/v7-date.ts +47 -0
  81. package/src/worker/heartbeat.ts +46 -24
  82. package/src/worker/index.ts +13 -15
  83. package/src/worker/llm.ts +30 -37
  84. package/src/worker/prompt.ts +19 -41
  85. package/src/worker/schedules.ts +48 -69
  86. package/src/worker/spawn.ts +11 -11
  87. package/src/worker/tick.ts +39 -43
  88. package/src/workers/store.ts +247 -0
  89. package/src/commands/tools.ts +0 -367
  90. package/src/context/describer.ts +0 -140
  91. package/src/context/drives.ts +0 -110
  92. package/src/context/ingest.ts +0 -162
  93. package/src/context/refresh.ts +0 -183
  94. package/src/db/context.ts +0 -637
  95. package/src/db/daemon-state.ts +0 -6
  96. package/src/db/reembed.ts +0 -113
  97. package/src/db/schedules.ts +0 -213
  98. package/src/db/tasks.ts +0 -347
  99. package/src/db/threads.ts +0 -276
  100. package/src/db/workers.ts +0 -212
  101. package/src/tools/context/list-drives.ts +0 -36
  102. package/src/tools/context/refresh.ts +0 -165
  103. package/src/tools/context/search.ts +0 -54
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { createTask, TASK_PRIORITIES } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES } from "../../tasks/schema.ts";
3
+ import { CircularDependencyError, createTask } from "../../tasks/store.ts";
3
4
  import { logger } from "../../utils/logger.ts";
4
5
  import type { ToolDefinition } from "../tool.ts";
5
6
 
@@ -21,13 +22,21 @@ const inputSchema = z.object({
21
22
  .array(z.string())
22
23
  .optional()
23
24
  .describe("IDs of tasks that must complete first"),
25
+ context_paths: z
26
+ .array(z.string())
27
+ .optional()
28
+ .describe(
29
+ "Project-relative paths under context/ that the task should reference",
30
+ ),
24
31
  });
25
32
 
26
33
  const outputSchema = z.object({
27
- id: z.string(),
28
- name: z.string(),
34
+ id: z.string().nullable(),
35
+ name: z.string().nullable(),
29
36
  message: z.string(),
30
37
  is_error: z.boolean(),
38
+ error_type: z.string().optional(),
39
+ next_action_hint: z.string().optional(),
31
40
  });
32
41
 
33
42
  export const createTaskTool = {
@@ -38,18 +47,34 @@ export const createTaskTool = {
38
47
  inputSchema,
39
48
  outputSchema,
40
49
  execute: async (input, ctx) => {
41
- const newTask = await createTask(ctx.conn, {
42
- name: input.name,
43
- description: input.description,
44
- priority: input.priority,
45
- blocked_by: input.blocked_by,
46
- });
47
- logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
48
- return {
49
- id: newTask.id,
50
- name: newTask.name,
51
- message: `Created task "${newTask.name}" with ID ${newTask.id}`,
52
- is_error: false,
53
- };
50
+ try {
51
+ const newTask = await createTask(ctx.projectDir, {
52
+ name: input.name,
53
+ description: input.description,
54
+ priority: input.priority,
55
+ blocked_by: input.blocked_by,
56
+ context_paths: input.context_paths,
57
+ });
58
+ logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
59
+ return {
60
+ id: newTask.id,
61
+ name: newTask.name,
62
+ message: `Created task "${newTask.name}" with ID ${newTask.id}`,
63
+ is_error: false,
64
+ };
65
+ } catch (err) {
66
+ if (err instanceof CircularDependencyError) {
67
+ return {
68
+ id: null,
69
+ name: null,
70
+ message: err.message,
71
+ is_error: true,
72
+ error_type: "circular_dependency",
73
+ next_action_hint:
74
+ "Pick blockers that don't transitively depend on this task. `list_tasks` + `view_task` show the existing dependency graph.",
75
+ };
76
+ }
77
+ throw err;
78
+ }
54
79
  },
55
80
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { deleteTask, getTask } from "../../db/tasks.ts";
2
+ import { deleteTask, getTask } from "../../tasks/store.ts";
3
3
  import { logger } from "../../utils/logger.ts";
4
4
  import type { ToolDefinition } from "../tool.ts";
5
5
 
@@ -21,7 +21,7 @@ export const deleteTaskTool = {
21
21
  inputSchema,
22
22
  outputSchema,
23
23
  execute: async (input, ctx) => {
24
- const existing = await getTask(ctx.conn, input.id);
24
+ const existing = await getTask(ctx.projectDir, input.id);
25
25
  if (!existing) {
26
26
  return {
27
27
  deleted_id: null,
@@ -36,7 +36,7 @@ export const deleteTaskTool = {
36
36
  is_error: true,
37
37
  };
38
38
  }
39
- const ok = await deleteTask(ctx.conn, input.id);
39
+ const ok = await deleteTask(ctx.projectDir, input.id);
40
40
  if (!ok) {
41
41
  return {
42
42
  deleted_id: null,
@@ -1,11 +1,13 @@
1
1
  import { z } from "zod";
2
- import { listTasks, TASK_PRIORITIES, TASK_STATUSES } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES, TASK_STATUSES } from "../../tasks/schema.ts";
3
+ import { listTasks } from "../../tasks/store.ts";
3
4
  import type { ToolDefinition } from "../tool.ts";
4
5
 
5
6
  const inputSchema = z.object({
6
7
  status: z.enum(TASK_STATUSES).optional().describe("Filter by status"),
7
8
  priority: z.enum(TASK_PRIORITIES).optional().describe("Filter by priority"),
8
9
  limit: z.number().optional().describe("Max number of tasks to return"),
10
+ offset: z.number().optional().describe("Skip first N tasks"),
9
11
  });
10
12
 
11
13
  const outputSchema = z.object({
@@ -31,10 +33,11 @@ export const listTasksTool = {
31
33
  inputSchema,
32
34
  outputSchema,
33
35
  execute: async (input, ctx) => {
34
- const tasks = await listTasks(ctx.conn, {
36
+ const tasks = await listTasks(ctx.projectDir, {
35
37
  status: input.status,
36
38
  priority: input.priority,
37
39
  limit: input.limit,
40
+ offset: input.offset,
38
41
  });
39
42
  return {
40
43
  tasks: tasks.map((t) => ({
@@ -44,7 +47,7 @@ export const listTasksTool = {
44
47
  priority: t.priority,
45
48
  description: t.description,
46
49
  output: t.output,
47
- created_at: t.created_at.toISOString(),
50
+ created_at: t.created_at,
48
51
  })),
49
52
  count: tasks.length,
50
53
  is_error: false,
@@ -1,5 +1,10 @@
1
1
  import { z } from "zod";
2
- import { getTask, TASK_PRIORITIES, updateTask } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES } from "../../tasks/schema.ts";
3
+ import {
4
+ CircularDependencyError,
5
+ getTask,
6
+ updateTask,
7
+ } from "../../tasks/store.ts";
3
8
  import { logger } from "../../utils/logger.ts";
4
9
  import type { ToolDefinition } from "../tool.ts";
5
10
 
@@ -28,6 +33,8 @@ const outputSchema = z.object({
28
33
  .nullable(),
29
34
  message: z.string(),
30
35
  is_error: z.boolean(),
36
+ error_type: z.string().optional(),
37
+ next_action_hint: z.string().optional(),
31
38
  });
32
39
 
33
40
  export const updateTaskTool = {
@@ -38,7 +45,7 @@ export const updateTaskTool = {
38
45
  inputSchema,
39
46
  outputSchema,
40
47
  execute: async (input, ctx) => {
41
- const existing = await getTask(ctx.conn, input.id);
48
+ const existing = await getTask(ctx.projectDir, input.id);
42
49
  if (!existing) {
43
50
  return {
44
51
  task: null,
@@ -54,12 +61,27 @@ export const updateTaskTool = {
54
61
  };
55
62
  }
56
63
 
57
- const updated = await updateTask(ctx.conn, input.id, {
58
- name: input.name,
59
- description: input.description,
60
- priority: input.priority,
61
- blocked_by: input.blocked_by,
62
- });
64
+ let updated: Awaited<ReturnType<typeof updateTask>>;
65
+ try {
66
+ updated = await updateTask(ctx.projectDir, input.id, {
67
+ name: input.name,
68
+ description: input.description,
69
+ priority: input.priority,
70
+ blocked_by: input.blocked_by,
71
+ });
72
+ } catch (err) {
73
+ if (err instanceof CircularDependencyError) {
74
+ return {
75
+ task: null,
76
+ message: err.message,
77
+ is_error: true,
78
+ error_type: "circular_dependency",
79
+ next_action_hint:
80
+ "Pick blockers that don't transitively depend on this task.",
81
+ };
82
+ }
83
+ throw err;
84
+ }
63
85
 
64
86
  if (!updated) {
65
87
  return {
@@ -78,7 +100,7 @@ export const updateTaskTool = {
78
100
  status: updated.status,
79
101
  priority: updated.priority,
80
102
  blocked_by: updated.blocked_by,
81
- updated_at: updated.updated_at.toISOString(),
103
+ updated_at: updated.updated_at,
82
104
  },
83
105
  message: `Updated task "${updated.name}"`,
84
106
  is_error: false,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getTask } from "../../db/tasks.ts";
2
+ import { getTask } from "../../tasks/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -18,7 +18,7 @@ const outputSchema = z.object({
18
18
  output: z.string().nullable(),
19
19
  claimed_by: z.string().nullable(),
20
20
  blocked_by: z.array(z.string()),
21
- context_ids: z.array(z.string()),
21
+ context_paths: z.array(z.string()),
22
22
  created_at: z.string(),
23
23
  updated_at: z.string(),
24
24
  })
@@ -33,7 +33,7 @@ export const viewTaskTool = {
33
33
  inputSchema,
34
34
  outputSchema,
35
35
  execute: async (input, ctx) => {
36
- const task = await getTask(ctx.conn, input.id);
36
+ const task = await getTask(ctx.projectDir, input.id);
37
37
  if (!task) return { task: null, is_error: true };
38
38
  return {
39
39
  task: {
@@ -46,9 +46,9 @@ export const viewTaskTool = {
46
46
  output: task.output,
47
47
  claimed_by: task.claimed_by,
48
48
  blocked_by: task.blocked_by,
49
- context_ids: task.context_ids,
50
- created_at: task.created_at.toISOString(),
51
- updated_at: task.updated_at.toISOString(),
49
+ context_paths: task.context_paths,
50
+ created_at: task.created_at,
51
+ updated_at: task.updated_at,
52
52
  },
53
53
  is_error: false,
54
54
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { listThreads } from "../../db/threads.ts";
2
+ import { listThreads } from "../../threads/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -32,7 +32,7 @@ export const listThreadsTool = {
32
32
  inputSchema,
33
33
  outputSchema,
34
34
  execute: async (input, ctx) => {
35
- const threads = await listThreads(ctx.conn, {
35
+ const threads = await listThreads(ctx.projectDir, {
36
36
  type: input.type,
37
37
  limit: input.limit,
38
38
  });
@@ -0,0 +1,208 @@
1
+ import { z } from "zod";
2
+ import {
3
+ getThread,
4
+ type Interaction,
5
+ type InteractionKind,
6
+ type InteractionRole,
7
+ listThreads,
8
+ type Thread,
9
+ } from "../../threads/store.ts";
10
+ import type { ToolDefinition } from "../tool.ts";
11
+
12
+ const ROLES = ["user", "assistant", "system", "tool"] as const;
13
+ const KINDS = [
14
+ "message",
15
+ "thinking",
16
+ "tool_use",
17
+ "tool_result",
18
+ "context_update",
19
+ "status_change",
20
+ ] as const;
21
+
22
+ const SNIPPET_MAX = 240;
23
+
24
+ const inputSchema = z.object({
25
+ pattern: z
26
+ .string()
27
+ .describe(
28
+ "Regex pattern matched against each interaction's `content`. Use a plain substring (it's a regex, but plain text Just Works).",
29
+ ),
30
+ ignore_case: z
31
+ .boolean()
32
+ .optional()
33
+ .default(true)
34
+ .describe("Case-insensitive regex (default true)."),
35
+ role: z
36
+ .enum(ROLES)
37
+ .optional()
38
+ .describe(
39
+ "Restrict matches to a single role (user/assistant/system/tool).",
40
+ ),
41
+ kind: z
42
+ .enum(KINDS)
43
+ .optional()
44
+ .describe(
45
+ "Restrict matches to a single interaction kind (message/tool_use/tool_result/etc).",
46
+ ),
47
+ thread_type: z
48
+ .enum(["worker_tick", "chat_session"])
49
+ .optional()
50
+ .describe("Restrict to chat sessions or worker-tick threads."),
51
+ since: z
52
+ .string()
53
+ .optional()
54
+ .describe("ISO date — only consider threads started on or after this."),
55
+ until: z
56
+ .string()
57
+ .optional()
58
+ .describe("ISO date — only consider threads started on or before this."),
59
+ max_results: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .optional()
64
+ .default(20)
65
+ .describe("Maximum number of hits to return across all threads."),
66
+ });
67
+
68
+ const HitSchema = z.object({
69
+ thread_id: z.string(),
70
+ thread_title: z.string(),
71
+ thread_type: z.string(),
72
+ sequence: z
73
+ .number()
74
+ .describe(
75
+ "1-based sequence of the matching interaction in the thread. Plug this into `view_thread({ id, offset: sequence-1, limit: 5 })` to read context around the hit.",
76
+ ),
77
+ role: z.string(),
78
+ kind: z.string(),
79
+ content_snippet: z.string(),
80
+ created_at: z.string(),
81
+ });
82
+
83
+ const outputSchema = z.object({
84
+ matches: z.array(HitSchema),
85
+ threads_scanned: z.number(),
86
+ is_error: z.boolean(),
87
+ error_type: z.string().optional(),
88
+ message: z.string().optional(),
89
+ next_action_hint: z.string().optional(),
90
+ });
91
+
92
+ export const searchThreadsTool = {
93
+ name: "search_threads",
94
+ description:
95
+ "[[ bash equivalent command: grep -r ]] Search past conversations (chat sessions and worker ticks) for a regex match. Returns hits with `(thread_id, sequence)` pairs — pass them to `view_thread` to read context around the match.",
96
+ group: "thread",
97
+ inputSchema,
98
+ outputSchema,
99
+ execute: async (input, ctx) => {
100
+ let regex: RegExp;
101
+ try {
102
+ regex = new RegExp(input.pattern, input.ignore_case ? "i" : "");
103
+ } catch (err) {
104
+ return {
105
+ matches: [],
106
+ threads_scanned: 0,
107
+ is_error: true,
108
+ error_type: "invalid_regex",
109
+ message: `Could not compile pattern: ${err instanceof Error ? err.message : String(err)}`,
110
+ next_action_hint:
111
+ "Double-check the regex; remember `.` is a metacharacter — escape it as `\\.` for a literal dot.",
112
+ };
113
+ }
114
+
115
+ const sinceMs = input.since
116
+ ? Date.parse(input.since)
117
+ : Number.NEGATIVE_INFINITY;
118
+ const untilMs = input.until
119
+ ? Date.parse(input.until)
120
+ : Number.POSITIVE_INFINITY;
121
+
122
+ let threads: Thread[];
123
+ try {
124
+ threads = await listThreads(ctx.projectDir, {
125
+ type: input.thread_type,
126
+ });
127
+ } catch (err) {
128
+ return {
129
+ matches: [],
130
+ threads_scanned: 0,
131
+ is_error: true,
132
+ error_type: "list_failed",
133
+ message: `Failed to enumerate threads: ${err instanceof Error ? err.message : String(err)}`,
134
+ };
135
+ }
136
+
137
+ type Hit = z.infer<typeof HitSchema>;
138
+ const matches: Hit[] = [];
139
+ let scanned = 0;
140
+
141
+ for (const t of threads) {
142
+ const startedMs = t.started_at.getTime();
143
+ if (startedMs < sinceMs || startedMs > untilMs) continue;
144
+ const data = await getThread(ctx.projectDir, t.id);
145
+ if (!data) continue;
146
+ scanned++;
147
+ for (const ix of data.interactions) {
148
+ if (input.role && ix.role !== input.role) continue;
149
+ if (input.kind && ix.kind !== input.kind) continue;
150
+ if (!matchInteraction(ix, regex)) continue;
151
+ matches.push({
152
+ thread_id: t.id,
153
+ thread_title: t.title || "(untitled)",
154
+ thread_type: t.type,
155
+ sequence: ix.sequence,
156
+ role: ix.role,
157
+ kind: ix.kind,
158
+ content_snippet: snippetForMatch(ix.content, regex),
159
+ created_at: ix.created_at.toISOString(),
160
+ });
161
+ if (matches.length >= input.max_results) break;
162
+ }
163
+ if (matches.length >= input.max_results) break;
164
+ }
165
+
166
+ return {
167
+ matches,
168
+ threads_scanned: scanned,
169
+ is_error: false,
170
+ next_action_hint:
171
+ matches.length === 0
172
+ ? `No hits in ${scanned} thread(s). Try a broader pattern or remove role/kind filters.`
173
+ : `Pass any (thread_id, sequence) into view_thread({ id: thread_id, offset: sequence - 1, limit: 5 }) to read surrounding context.`,
174
+ };
175
+ },
176
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
177
+
178
+ function matchInteraction(ix: Interaction, regex: RegExp): boolean {
179
+ // We treat the user-visible content as the primary haystack, but a
180
+ // tool_use interaction's content is just "Calling <name>" — fall through
181
+ // to the tool name + JSON args so a search for an exact tool argument
182
+ // still finds the call.
183
+ if (regex.test(ix.content)) return true;
184
+ if (ix.tool_name && regex.test(ix.tool_name)) return true;
185
+ if (ix.tool_input && regex.test(ix.tool_input)) return true;
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Pick a short window around the first regex match so the agent gets enough
191
+ * context to know whether the hit is relevant without paging the whole
192
+ * interaction. Falls back to the head when the match index isn't available.
193
+ */
194
+ function snippetForMatch(content: string, regex: RegExp): string {
195
+ const m = regex.exec(content);
196
+ if (!m) return content.slice(0, SNIPPET_MAX);
197
+ const idx = m.index;
198
+ const start = Math.max(0, idx - 60);
199
+ const end = Math.min(content.length, idx + SNIPPET_MAX - 60);
200
+ let snippet = content.slice(start, end);
201
+ if (start > 0) snippet = `…${snippet}`;
202
+ if (end < content.length) snippet = `${snippet}…`;
203
+ return snippet;
204
+ }
205
+
206
+ // Keep the role/kind unions exported for tests that want to type-pin filters.
207
+ export type SearchThreadsRole = InteractionRole;
208
+ export type SearchThreadsKind = InteractionKind;
@@ -1,9 +1,27 @@
1
1
  import { z } from "zod";
2
- import { getThread } from "../../db/threads.ts";
2
+ import { getThread } from "../../threads/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
6
  id: z.string().describe("Thread ID to view"),
7
+ offset: z
8
+ .number()
9
+ .int()
10
+ .nonnegative()
11
+ .optional()
12
+ .default(0)
13
+ .describe(
14
+ "1-based sequence to start from (skip earlier interactions). Use with `limit` to paginate long threads.",
15
+ ),
16
+ limit: z
17
+ .number()
18
+ .int()
19
+ .positive()
20
+ .optional()
21
+ .default(50)
22
+ .describe(
23
+ "Max interactions to return in this page. Default 50 keeps long threads from blowing the LLM context window.",
24
+ ),
7
25
  });
8
26
 
9
27
  const outputSchema = z.object({
@@ -28,19 +46,39 @@ const outputSchema = z.object({
28
46
  created_at: z.string(),
29
47
  }),
30
48
  ),
49
+ total_interactions: z.number(),
50
+ offset: z.number(),
51
+ limit: z.number(),
52
+ has_more: z.boolean(),
31
53
  is_error: z.boolean(),
54
+ next_action_hint: z.string().optional(),
32
55
  });
33
56
 
34
57
  export const viewThreadTool = {
35
58
  name: "view_thread",
36
59
  description:
37
- "View a thread and its full interaction log (messages, tool calls, results).",
60
+ "View a thread's metadata and a paginated slice of its interactions. Pass `offset` (sequence to start from) and `limit` to walk a long thread without flooding the context window. `search_threads` returns `(thread_id, sequence)` pairs you can plug into `offset` to jump straight to a hit.",
38
61
  group: "thread",
39
62
  inputSchema,
40
63
  outputSchema,
41
64
  execute: async (input, ctx) => {
42
- const result = await getThread(ctx.conn, input.id);
43
- if (!result) return { thread: null, interactions: [], is_error: false };
65
+ const result = await getThread(ctx.projectDir, input.id);
66
+ if (!result) {
67
+ return {
68
+ thread: null,
69
+ interactions: [],
70
+ total_interactions: 0,
71
+ offset: input.offset,
72
+ limit: input.limit,
73
+ has_more: false,
74
+ is_error: false,
75
+ };
76
+ }
77
+ const total = result.interactions.length;
78
+ const start = Math.min(input.offset, total);
79
+ const end = Math.min(start + input.limit, total);
80
+ const page = result.interactions.slice(start, end);
81
+ const hasMore = end < total;
44
82
  return {
45
83
  thread: {
46
84
  id: result.thread.id,
@@ -50,7 +88,7 @@ export const viewThreadTool = {
50
88
  started_at: result.thread.started_at.toISOString(),
51
89
  ended_at: result.thread.ended_at?.toISOString() ?? null,
52
90
  },
53
- interactions: result.interactions.map((i) => ({
91
+ interactions: page.map((i) => ({
54
92
  id: i.id,
55
93
  sequence: i.sequence,
56
94
  role: i.role,
@@ -59,6 +97,13 @@ export const viewThreadTool = {
59
97
  tool_name: i.tool_name,
60
98
  created_at: i.created_at.toISOString(),
61
99
  })),
100
+ total_interactions: total,
101
+ offset: start,
102
+ limit: input.limit,
103
+ has_more: hasMore,
104
+ next_action_hint: hasMore
105
+ ? `Call view_thread again with offset=${end} to see the next page.`
106
+ : undefined,
62
107
  is_error: false,
63
108
  };
64
109
  },
@@ -18,10 +18,12 @@ const inputSchema = z.object({
18
18
  });
19
19
 
20
20
  const outputSchema = z.object({
21
- worker_pid: z.number(),
21
+ worker_pid: z.number().nullable(),
22
22
  mode: z.enum(["once", "persist"]),
23
23
  message: z.string(),
24
24
  is_error: z.boolean(),
25
+ error_type: z.string().optional(),
26
+ next_action_hint: z.string().optional(),
25
27
  });
26
28
 
27
29
  export const spawnWorkerTool = {
@@ -33,18 +35,30 @@ export const spawnWorkerTool = {
33
35
  outputSchema,
34
36
  execute: async (input, ctx) => {
35
37
  const mode = input.persist ? "persist" : "once";
36
- const { pid } = await spawnWorker(ctx.projectDir, {
37
- mode,
38
- taskId: input.task_id,
39
- });
40
- const target = input.task_id
41
- ? `task ${input.task_id}`
42
- : "next eligible task";
43
- return {
44
- worker_pid: pid,
45
- mode,
46
- message: `Spawned ${mode} worker (pid ${pid}) for ${target}.`,
47
- is_error: false,
48
- };
38
+ try {
39
+ const { pid } = await spawnWorker(ctx.projectDir, {
40
+ mode,
41
+ taskId: input.task_id,
42
+ });
43
+ const target = input.task_id
44
+ ? `task ${input.task_id}`
45
+ : "next eligible task";
46
+ return {
47
+ worker_pid: pid,
48
+ mode,
49
+ message: `Spawned ${mode} worker (pid ${pid}) for ${target}.`,
50
+ is_error: false,
51
+ };
52
+ } catch (err) {
53
+ return {
54
+ worker_pid: null,
55
+ mode,
56
+ message: err instanceof Error ? err.message : String(err),
57
+ is_error: true,
58
+ error_type: "spawn_failed",
59
+ next_action_hint:
60
+ "Bun must be on PATH for the spawned child to launch. Confirm with `which bun` from the same shell that runs the agent.",
61
+ };
62
+ }
49
63
  },
50
64
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;