talon-agent 5.1.0 → 5.2.1

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 (100) hide show
  1. package/README.md +3 -1
  2. package/bin/talon.js +35 -0
  3. package/package.json +2 -2
  4. package/prompts/identity.md +10 -2
  5. package/prompts/system/agent-brief.md +43 -0
  6. package/src/app.ts +60 -58
  7. package/src/backend/builtins.ts +26 -7
  8. package/src/backend/claude-sdk/one-shot.ts +13 -5
  9. package/src/backend/remote-server/index.ts +6 -4
  10. package/src/backend/remote-server/model-catalog/index.ts +4 -10
  11. package/src/backend/remote-server/model-catalog/provider.ts +3 -3
  12. package/src/backend/remote-server/profiles/bind.ts +225 -0
  13. package/src/backend/remote-server/profiles/index.ts +10 -0
  14. package/src/backend/remote-server/profiles/kilo.ts +82 -0
  15. package/src/backend/remote-server/profiles/opencode.ts +61 -0
  16. package/src/backend/remote-server/server-bindings.ts +3 -4
  17. package/src/bootstrap.ts +15 -1
  18. package/src/cli/chat.ts +5 -0
  19. package/src/cli/events.ts +9 -0
  20. package/src/core/agents/context.ts +48 -0
  21. package/src/core/agents/delivery.ts +167 -0
  22. package/src/core/agents/index.ts +37 -0
  23. package/src/core/agents/prompt.ts +116 -0
  24. package/src/core/agents/registry.ts +426 -0
  25. package/src/core/agents/runner.ts +448 -0
  26. package/src/core/agents/types.ts +124 -0
  27. package/src/core/background/cron/job-oneshot.ts +7 -12
  28. package/src/core/background/cron/job-prompt.ts +1 -1
  29. package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
  30. package/src/core/background/run-log.ts +33 -0
  31. package/src/core/bus/events.ts +49 -2
  32. package/src/core/config/index.ts +25 -0
  33. package/src/core/daemon/crash.ts +82 -0
  34. package/src/core/engine/gateway-actions/agents/control.ts +299 -0
  35. package/src/core/engine/gateway-actions/agents/index.ts +31 -0
  36. package/src/core/engine/gateway-actions/agents/report.ts +107 -0
  37. package/src/core/engine/gateway-actions/index.ts +30 -0
  38. package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
  39. package/src/core/engine/gateway-actions/native/exec.ts +1 -1
  40. package/src/core/engine/gateway-actions/native/read.ts +1 -1
  41. package/src/core/engine/gateway-actions/native/search.ts +1 -1
  42. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  43. package/src/core/engine/gateway-actions/native/write.ts +1 -1
  44. package/src/core/engine/gateway-routes.ts +12 -0
  45. package/src/core/engine/gateway.ts +96 -25
  46. package/src/core/frontend-runtime/capabilities.ts +18 -0
  47. package/src/core/frontend-runtime/index.ts +4 -0
  48. package/src/core/frontend-runtime/lifecycle.ts +33 -0
  49. package/src/core/frontend-runtime/registry.ts +3 -3
  50. package/src/core/frontend-runtime/run-loop.ts +59 -0
  51. package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
  52. package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
  53. package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
  54. package/src/core/mesh/index.ts +6 -2
  55. package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
  56. package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
  57. package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
  58. package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
  59. package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
  60. package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
  61. package/src/core/prompt/embedded-prompts.ts +38 -36
  62. package/src/core/tasks/types.ts +2 -2
  63. package/src/core/tools/index.ts +5 -0
  64. package/src/core/tools/ops/agents.ts +195 -0
  65. package/src/core/tools/ops/bridge.ts +4 -0
  66. package/src/core/tools/types.ts +1 -0
  67. package/src/core/types.ts +1 -1
  68. package/src/frontend/discord/commands/info.ts +1 -1
  69. package/src/frontend/discord/render.ts +1 -1
  70. package/src/frontend/native/bridge/routes/mesh.ts +1 -1
  71. package/src/frontend/native/index.ts +2 -0
  72. package/src/frontend/presentation/reports.ts +1 -1
  73. package/src/frontend/teams/index.ts +4 -3
  74. package/src/frontend/telegram/commands/info.ts +61 -20
  75. package/src/frontend/telegram/index.ts +27 -4
  76. package/src/frontend/telegram/render/reports.ts +1 -1
  77. package/src/frontend/terminal/index.ts +6 -2
  78. package/src/frontend/whatsapp/connection/connection.ts +62 -11
  79. package/src/frontend/whatsapp/index.ts +25 -1
  80. package/src/frontend/whatsapp/runtime.ts +7 -0
  81. package/src/util/log.ts +264 -18
  82. package/src/backend/kilo/factory.ts +0 -53
  83. package/src/backend/kilo/handler/index.ts +0 -2
  84. package/src/backend/kilo/handler/message.ts +0 -44
  85. package/src/backend/kilo/index.ts +0 -61
  86. package/src/backend/kilo/model-provider.ts +0 -36
  87. package/src/backend/kilo/models/index.ts +0 -55
  88. package/src/backend/kilo/one-shot.ts +0 -42
  89. package/src/backend/kilo/server.ts +0 -98
  90. package/src/backend/kilo/sessions.ts +0 -37
  91. package/src/backend/opencode/factory.ts +0 -53
  92. package/src/backend/opencode/handler/index.ts +0 -2
  93. package/src/backend/opencode/handler/message.ts +0 -44
  94. package/src/backend/opencode/index.ts +0 -42
  95. package/src/backend/opencode/model-provider.ts +0 -36
  96. package/src/backend/opencode/models/index.ts +0 -54
  97. package/src/backend/opencode/one-shot.ts +0 -42
  98. package/src/backend/opencode/server.ts +0 -80
  99. package/src/backend/opencode/sessions.ts +0 -35
  100. /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Runner — turns a spawn request into a live isolated run, and a finished
3
+ * run into a settled record its parent hears about.
4
+ *
5
+ * The shape is the heartbeat / cron-job shape, because a sub-agent *is* one
6
+ * of those: acquire a backend, resolve a model, open a run log, register a
7
+ * task, and hand `runOneShotAgent` to `runIsolatedAgent` for the hard
8
+ * timeout → abort → grace → eviction discipline. Nothing here is
9
+ * backend-specific, which is the whole point: sub-agents work on Claude,
10
+ * Codex, Kilo, OpenCode and any future backend with a background capability.
11
+ *
12
+ * What is specific to agents:
13
+ *
14
+ * - **Identity.** Each run gets `contextLabel: "agent:<id>"`, which the
15
+ * backends turn into a per-agent MCP tool session and the gateway reads
16
+ * back to know which agent is calling `report_result`.
17
+ * - **Result precedence.** The `report_result` tool wins; otherwise the
18
+ * run's last assistant text is used; with neither, the run is a failure,
19
+ * because a sub-agent that says nothing has not done its job.
20
+ * - **Settlement is the delivery trigger.** Every terminal state — done,
21
+ * failed, killed, timed out — reaches the parent. Silence is never an
22
+ * outcome.
23
+ *
24
+ * `spawnAgent` returns as soon as the run is under way: the caller (a chat
25
+ * turn or another agent) keeps working and hears back through the wake turn
26
+ * or its mailbox.
27
+ */
28
+
29
+ import { dirs } from "../../util/paths.js";
30
+ import { log, logError, logWarn } from "../../util/log.js";
31
+ import {
32
+ acquireBackendInstance,
33
+ getBackendIdForChat,
34
+ isModelValidForBackend,
35
+ } from "../engine/backend-controller/index.js";
36
+ import type {
37
+ Backend,
38
+ BackgroundRunner,
39
+ } from "../agent-runtime/capabilities.js";
40
+ import { taskTable } from "../tasks/index.js";
41
+ import type { TaskHandle, TaskUsage } from "../tasks/types.js";
42
+ import type { OneShotAgentParams } from "../types.js";
43
+ import {
44
+ IsolatedAgentTimeoutError,
45
+ runIsolatedAgent,
46
+ } from "../background/isolated-agent.js";
47
+ import { openRunLog } from "../background/run-log.js";
48
+ import { agentContextLabel } from "./context.js";
49
+ import {
50
+ deliverSettlement,
51
+ initAgentDelivery,
52
+ type AgentDeliveryDeps,
53
+ } from "./delivery.js";
54
+ import {
55
+ agentLogHeader,
56
+ agentLogPath,
57
+ buildAgentPrompt,
58
+ buildAgentSystemPrompt,
59
+ } from "./prompt.js";
60
+ import { agentRegistry } from "./registry.js";
61
+ import type {
62
+ AgentCaps,
63
+ AgentParent,
64
+ AgentRecord,
65
+ AgentSpawnOutcome,
66
+ AgentSpawnSpec,
67
+ } from "./types.js";
68
+
69
+ /** Defaults for `config.agents`, applied when the block is absent. */
70
+ export const DEFAULT_AGENT_CAPS: AgentCaps = {
71
+ maxConcurrent: 6,
72
+ maxDepth: 2,
73
+ defaultTimeoutMs: 15 * 60 * 1000,
74
+ };
75
+
76
+ /** Floor and ceiling the tool boundary clamps a requested `timeout_s` into. */
77
+ const MIN_TIMEOUT_MS = 30_000;
78
+ const MAX_TIMEOUT_MS = 60 * 60 * 1000;
79
+
80
+ const capsHolder: { caps: AgentCaps } = { caps: DEFAULT_AGENT_CAPS };
81
+
82
+ /** Wire the sub-agent subsystem. Called once from the composition root. */
83
+ export function initAgents(
84
+ deps: AgentDeliveryDeps & { caps?: Partial<AgentCaps> },
85
+ ): void {
86
+ capsHolder.caps = { ...DEFAULT_AGENT_CAPS, ...deps.caps };
87
+ initAgentDelivery({ execute: deps.execute });
88
+ log(
89
+ "agents",
90
+ `Initialized — maxConcurrent=${capsHolder.caps.maxConcurrent} ` +
91
+ `maxDepth=${capsHolder.caps.maxDepth} ` +
92
+ `timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s`,
93
+ );
94
+ }
95
+
96
+ /** The live caps — read by the tools for their error copy and prompts. */
97
+ export function getAgentCaps(): AgentCaps {
98
+ return capsHolder.caps;
99
+ }
100
+
101
+ /**
102
+ * Clamp a model-supplied timeout into the supported window, or fall back to
103
+ * the configured default. Applied at the tool boundary — `spawnAgent` itself
104
+ * honours whatever it is handed, so the runner has one rule and not two.
105
+ */
106
+ export function clampTimeout(requestedMs: number | undefined): number {
107
+ if (requestedMs === undefined) return capsHolder.caps.defaultTimeoutMs;
108
+ return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, requestedMs));
109
+ }
110
+
111
+ /** The backend an agent inherits when the caller didn't pick one. */
112
+ function inheritedBackendId(parent: AgentParent): string | null {
113
+ if (parent.kind === "chat") return getBackendIdForChat(parent.chatId);
114
+ return agentRegistry.get(parent.agentId)?.backendId ?? null;
115
+ }
116
+
117
+ /** The chat a run's task belongs to, for `talon ps`. */
118
+ function taskChatId(parent: AgentParent): string | undefined {
119
+ if (parent.kind === "chat") return parent.chatId;
120
+ const root = agentRegistry.get(parent.agentId)?.parent;
121
+ return root?.kind === "chat" ? root.chatId : undefined;
122
+ }
123
+
124
+ function errText(err: unknown): string {
125
+ return err instanceof Error ? err.message : String(err);
126
+ }
127
+
128
+ /**
129
+ * Resolve the model for a run: an explicit id is validated against the
130
+ * backend, an absent one falls back to that backend's own default. Returns
131
+ * the error text a tool should show instead of throwing.
132
+ */
133
+ async function resolveRun(
134
+ backend: Backend,
135
+ backendId: string,
136
+ requested: string | undefined,
137
+ ): Promise<
138
+ | { ok: true; model: string; background: BackgroundRunner }
139
+ | { ok: false; error: string }
140
+ > {
141
+ const background = backend.background;
142
+ if (!background) {
143
+ return {
144
+ ok: false,
145
+ error:
146
+ `Backend "${backendId}" cannot host a sub-agent (it has no ` +
147
+ `background capability). Pick another backend or leave it unset.`,
148
+ };
149
+ }
150
+ if (requested) {
151
+ let valid = false;
152
+ try {
153
+ valid = await isModelValidForBackend(backend, requested);
154
+ } catch (err) {
155
+ return {
156
+ ok: false,
157
+ error: `Could not validate model "${requested}" on backend "${backendId}": ${errText(err)}`,
158
+ };
159
+ }
160
+ if (!valid) {
161
+ return {
162
+ ok: false,
163
+ error:
164
+ `Model "${requested}" is not selectable on backend "${backendId}". ` +
165
+ `Call list_models to see valid ids, or leave model unset to use ` +
166
+ `that backend's default.`,
167
+ };
168
+ }
169
+ return { ok: true, model: requested, background };
170
+ }
171
+ let fallback: string | null | undefined;
172
+ try {
173
+ fallback = await backend.models?.getDefaultModelId();
174
+ } catch (err) {
175
+ return {
176
+ ok: false,
177
+ error: `Backend "${backendId}" could not report a default model: ${errText(err)}`,
178
+ };
179
+ }
180
+ if (!fallback) {
181
+ return {
182
+ ok: false,
183
+ error:
184
+ `Backend "${backendId}" has no default model — pass an explicit ` +
185
+ `model (list_models shows what it offers).`,
186
+ };
187
+ }
188
+ return { ok: true, model: fallback, background };
189
+ }
190
+
191
+ /**
192
+ * Spawn a sub-agent. Resolves once the run is under way (or refused) — never
193
+ * when the agent finishes; that arrives through delivery.
194
+ */
195
+ export async function spawnAgent(
196
+ spec: AgentSpawnSpec,
197
+ ): Promise<AgentSpawnOutcome> {
198
+ const backendId = spec.backendId ?? inheritedBackendId(spec.parent);
199
+ if (!backendId) {
200
+ return {
201
+ ok: false,
202
+ error:
203
+ "Could not resolve a backend for this agent — pass one explicitly.",
204
+ };
205
+ }
206
+
207
+ // Register first: the slot and the depth are claimed synchronously, so two
208
+ // concurrent spawns can never both slip past maxConcurrent while awaiting
209
+ // the backend. A registration that never starts is discarded without trace.
210
+ const registered = agentRegistry.register(
211
+ {
212
+ label: spec.label,
213
+ brief: spec.brief,
214
+ parent: spec.parent,
215
+ backendId,
216
+ ...(spec.reasoningEffort
217
+ ? { reasoningEffort: spec.reasoningEffort }
218
+ : {}),
219
+ },
220
+ capsHolder.caps,
221
+ );
222
+ if (!registered.ok) return registered;
223
+ const record = registered.record;
224
+
225
+ let acquired: Awaited<ReturnType<typeof acquireBackendInstance>>;
226
+ try {
227
+ acquired = await acquireBackendInstance(backendId);
228
+ } catch (err) {
229
+ agentRegistry.discard(record.id);
230
+ return {
231
+ ok: false,
232
+ error: `Backend "${backendId}" is unavailable: ${errText(err)}`,
233
+ };
234
+ }
235
+
236
+ const resolved = await resolveRun(acquired.backend, backendId, spec.model);
237
+ if (!resolved.ok) {
238
+ agentRegistry.discard(record.id);
239
+ await acquired.release();
240
+ return resolved;
241
+ }
242
+
243
+ // The run owns the instance from here: `runAgent` releases it on every
244
+ // path, including the ones that throw.
245
+ void runAgent(record, spec, resolved, acquired);
246
+ return { ok: true, agentId: record.id, backendId, model: resolved.model };
247
+ }
248
+
249
+ /** Build the one-shot params for a run, wired to its log and text capture. */
250
+ async function buildRunParams(
251
+ record: AgentRecord,
252
+ spec: AgentSpawnSpec,
253
+ model: string,
254
+ abortController: AbortController,
255
+ capture: { last: string },
256
+ ): Promise<OneShotAgentParams> {
257
+ const appendLog = await openRunLog(
258
+ agentLogPath(record.id),
259
+ agentLogHeader(record, model),
260
+ );
261
+ return {
262
+ prompt: buildAgentPrompt(record.brief),
263
+ systemPrompt: buildAgentSystemPrompt({
264
+ agentId: record.id,
265
+ label: record.label,
266
+ parent: record.parent,
267
+ depth: record.depth,
268
+ maxDepth: capsHolder.caps.maxDepth,
269
+ }),
270
+ workspace: dirs.workspace,
271
+ model,
272
+ contextLabel: agentContextLabel(record.id),
273
+ abortController,
274
+ appendLog,
275
+ onAssistantText: (text) => {
276
+ const trimmed = text.trim();
277
+ if (trimmed) capture.last = trimmed;
278
+ },
279
+ ...(spec.reasoningEffort ? { reasoningEffort: spec.reasoningEffort } : {}),
280
+ };
281
+ }
282
+
283
+ /** Settle a run that returned normally, applying the result precedence. */
284
+ function settleSuccess(
285
+ id: string,
286
+ task: TaskHandle,
287
+ lastText: string,
288
+ usage: TaskUsage | undefined,
289
+ ): AgentRecord | null {
290
+ if (agentRegistry.hasReported(id)) {
291
+ task.succeed(usage);
292
+ return agentRegistry.settle(id, {
293
+ state: "done",
294
+ ...(usage ? { usage } : {}),
295
+ });
296
+ }
297
+ if (lastText) {
298
+ task.succeed(usage);
299
+ return agentRegistry.settle(id, {
300
+ state: "done",
301
+ result: { summary: lastText },
302
+ ...(usage ? { usage } : {}),
303
+ });
304
+ }
305
+ const error =
306
+ "the agent finished without calling report_result and produced no text";
307
+ task.fail(new Error(error), usage);
308
+ return agentRegistry.settle(id, {
309
+ state: "failed",
310
+ error,
311
+ ...(usage ? { usage } : {}),
312
+ });
313
+ }
314
+
315
+ /** Settle a run that threw: timeout, kill, or a genuine failure. */
316
+ function settleFailure(
317
+ id: string,
318
+ task: TaskHandle,
319
+ err: unknown,
320
+ ): AgentRecord | null {
321
+ const state =
322
+ err instanceof IsolatedAgentTimeoutError
323
+ ? "timed_out"
324
+ : agentRegistry.killRequested(id)
325
+ ? "killed"
326
+ : "failed";
327
+ task.fail(err);
328
+ return agentRegistry.settle(id, { state, error: errText(err) });
329
+ }
330
+
331
+ /**
332
+ * Drive one run end to end. Never rejects — it is the tail of a
333
+ * fire-and-forget spawn, so every failure has to end as a settled record
334
+ * their parent is told about.
335
+ */
336
+ async function runAgent(
337
+ record: AgentRecord,
338
+ spec: AgentSpawnSpec,
339
+ resolved: { model: string; background: BackgroundRunner },
340
+ acquired: Awaited<ReturnType<typeof acquireBackendInstance>>,
341
+ ): Promise<void> {
342
+ const { model, background } = resolved;
343
+ const { release } = acquired;
344
+ const id = record.id;
345
+ const abortController = new AbortController();
346
+ const capture = { last: "" };
347
+ const timeoutMs = spec.timeoutMs ?? capsHolder.caps.defaultTimeoutMs;
348
+
349
+ // Registered as queued, bound, then started — so a kill arriving in the
350
+ // gap between the task existing and the abort handle being published still
351
+ // reaches the run.
352
+ const chatId = taskChatId(record.parent);
353
+ const task = taskTable.enqueue({
354
+ kind: "agent",
355
+ label: record.label,
356
+ abort: () => void agentRegistry.requestKill(id),
357
+ ...(chatId !== undefined ? { chatId } : {}),
358
+ });
359
+ task.bind({ model, backendId: record.backendId });
360
+ agentRegistry.start(id, { model, abort: abortController, taskId: task.id });
361
+ task.start();
362
+
363
+ let settled: AgentRecord | null = null;
364
+ try {
365
+ const params = await buildRunParams(
366
+ record,
367
+ spec,
368
+ model,
369
+ abortController,
370
+ capture,
371
+ );
372
+ if (abortController.signal.aborted) {
373
+ // A kill that lands during startup — while the backend is being
374
+ // acquired or the log opened — must not be lost. Handing an
375
+ // already-aborted signal to a backend relies on it checking, and not
376
+ // every SDK does; settling here is the one behaviour that always holds.
377
+ settled = settleFailure(
378
+ id,
379
+ task,
380
+ new Error("aborted before the run started"),
381
+ );
382
+ } else {
383
+ const usage = await runIsolatedAgent({
384
+ background,
385
+ params,
386
+ timeoutMs,
387
+ logCategory: "agents",
388
+ // Safe to sweep: the context label is unique to this agent, so no
389
+ // other context's subprocesses share the tag.
390
+ evictLabel: agentContextLabel(id),
391
+ });
392
+ settled = settleSuccess(id, task, capture.last, usage ?? undefined);
393
+ }
394
+ } catch (err) {
395
+ settled = settleFailure(id, task, err);
396
+ } finally {
397
+ await release().catch((err: unknown) =>
398
+ logError("agents", `failed to release backend for ${id}`, err),
399
+ );
400
+ }
401
+
402
+ if (!settled) return;
403
+ log(
404
+ "agents",
405
+ `${id} "${settled.label}" → ${settled.state} ` +
406
+ `(${settled.backendId}/${model}, ${timeoutMs}ms cap)`,
407
+ );
408
+ reapChildren(settled);
409
+ await deliverSettlement(settled).catch((err: unknown) =>
410
+ logError("agents", `delivery failed for ${id}`, err),
411
+ );
412
+ }
413
+
414
+ /**
415
+ * Kill a settled agent's still-running children. Their reports would have
416
+ * nowhere to go, so leaving them running only spends tokens.
417
+ */
418
+ function reapChildren(record: AgentRecord): void {
419
+ for (const child of agentRegistry.liveChildren(record.id)) {
420
+ logWarn(
421
+ "agents",
422
+ `killing ${child}: its parent ${record.id} settled as ${record.state}`,
423
+ );
424
+ killAgent(child);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Request a kill. Routed through the task table when the run has a task, so
430
+ * `talon ps` shows it as `killed` rather than `failed` — one kill path, two
431
+ * surfaces. Returns false when the agent is unknown or already settled.
432
+ */
433
+ export function killAgent(agentId: string): boolean {
434
+ const record = agentRegistry.get(agentId);
435
+ if (!record || !agentRegistry.isLive(agentId)) return false;
436
+ if (record.taskId !== undefined) return taskTable.kill(record.taskId).ok;
437
+ return agentRegistry.requestKill(agentId);
438
+ }
439
+
440
+ /**
441
+ * Abort every live agent — the shutdown lever, alongside heartbeat's and
442
+ * cron's. Returns how many kills were requested.
443
+ */
444
+ export function shutdownAgents(): number {
445
+ const killed = agentRegistry.killAll();
446
+ if (killed > 0) log("agents", `Shutdown: aborted ${killed} running agent(s)`);
447
+ return killed;
448
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Sub-agent vocabulary — the shapes the registry, runner and delivery share.
3
+ *
4
+ * A **sub-agent** is one isolated one-shot run that some other agent work
5
+ * started: a chat turn delegating a job, or another sub-agent fanning out.
6
+ * Talon owns the mechanism (not the Claude SDK's own sub-agents) so it works
7
+ * on every backend — the run is an ordinary `runOneShotAgent` with its own
8
+ * backend, model, workspace and tool surface.
9
+ */
10
+
11
+ import type { ReasoningEffortLevel } from "../types.js";
12
+ import type { TaskUsage } from "../tasks/types.js";
13
+
14
+ /**
15
+ * Lifecycle. `done` / `failed` / `killed` / `timed_out` are terminal.
16
+ *
17
+ * Wider than `TaskState` on purpose: a task that ran out of wall-clock is
18
+ * indistinguishable from any other abort in the task table, but the parent
19
+ * reading a report needs to know whether its agent was cut off.
20
+ */
21
+ export type AgentState =
22
+ "queued" | "running" | "done" | "failed" | "killed" | "timed_out";
23
+
24
+ /**
25
+ * Who spawned this agent, and therefore where its report goes.
26
+ *
27
+ * A chat parent is woken with a synthetic turn (`source: "agent"`), exactly
28
+ * as a trigger fires; an agent parent gets the report pushed into its
29
+ * mailbox, which it drains with `check_inbox`.
30
+ */
31
+ export type AgentParent =
32
+ | {
33
+ readonly kind: "chat";
34
+ /** Canonical string chat id — what every store is keyed on. */
35
+ readonly chatId: string;
36
+ /** The frontend's numeric id, needed by the dispatcher. */
37
+ readonly numericChatId: number;
38
+ }
39
+ | { readonly kind: "agent"; readonly agentId: string };
40
+
41
+ /** What an agent reports back when it finishes. */
42
+ export interface AgentResult {
43
+ readonly summary: string;
44
+ readonly details?: string;
45
+ }
46
+
47
+ /** One message waiting in an agent's mailbox. */
48
+ export interface AgentMessage {
49
+ /** Chat key or agent id of the sender. */
50
+ readonly from: string;
51
+ readonly text: string;
52
+ readonly at: number;
53
+ }
54
+
55
+ /** Immutable snapshot of one agent, as returned by the registry. */
56
+ export interface AgentRecord {
57
+ readonly id: string;
58
+ /** Short content-free name — safe for task labels, events and `talon ps`. */
59
+ readonly label: string;
60
+ /**
61
+ * The brief the agent was spawned with. Kept for the run log and the
62
+ * agent's own system prompt only — never in a task label or a bus event,
63
+ * which are content-free by contract.
64
+ */
65
+ readonly brief: string;
66
+ readonly parent: AgentParent;
67
+ readonly backendId: string;
68
+ /**
69
+ * Absent only in the moment between registration and the run starting —
70
+ * the model is whatever the backend's catalog resolved, which is an async
71
+ * answer, while the concurrency slot must be claimed synchronously.
72
+ */
73
+ readonly model?: string;
74
+ readonly reasoningEffort?: ReasoningEffortLevel;
75
+ readonly state: AgentState;
76
+ /** 0 for an agent spawned by a chat, +1 per generation below that. */
77
+ readonly depth: number;
78
+ readonly createdAt: number;
79
+ readonly startedAt?: number;
80
+ readonly endedAt?: number;
81
+ /** Report tool result, else the run's last assistant text, else null. */
82
+ readonly result: AgentResult | null;
83
+ readonly error?: string;
84
+ readonly usage?: TaskUsage;
85
+ readonly taskId?: number;
86
+ /** Ids of the agents this one spawned. */
87
+ readonly children: readonly string[];
88
+ /** Messages waiting to be drained by `check_inbox`. */
89
+ readonly inboxDepth: number;
90
+ }
91
+
92
+ /** What `spawnAgent` is asked for. */
93
+ export interface AgentSpawnSpec {
94
+ readonly brief: string;
95
+ readonly label: string;
96
+ readonly parent: AgentParent;
97
+ /** Defaults to the parent's backend. */
98
+ readonly backendId?: string;
99
+ /** Defaults to the resolved backend's own default model. */
100
+ readonly model?: string;
101
+ readonly reasoningEffort?: ReasoningEffortLevel;
102
+ /** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
103
+ readonly timeoutMs?: number;
104
+ }
105
+
106
+ /** `spawnAgent`'s answer — an error here is a tool error, never a throw. */
107
+ export type AgentSpawnOutcome =
108
+ | {
109
+ readonly ok: true;
110
+ readonly agentId: string;
111
+ readonly backendId: string;
112
+ readonly model: string;
113
+ }
114
+ | { readonly ok: false; readonly error: string };
115
+
116
+ /** The caps a deployment puts on sub-agent fan-out (config `agents`). */
117
+ export interface AgentCaps {
118
+ /** Live (queued + running) agents allowed per daemon. */
119
+ readonly maxConcurrent: number;
120
+ /** Deepest `depth` an agent may have — 2 means chat → A → B. */
121
+ readonly maxDepth: number;
122
+ /** Default hard timeout for one run. */
123
+ readonly defaultTimeoutMs: number;
124
+ }
@@ -10,10 +10,9 @@
10
10
  *
11
11
  * This module is deliberately thin: it owns acquisition + log wiring, and
12
12
  * delegates the prompt shape to {@link ./job-prompt} and the timeout/abort
13
- * discipline to {@link ./isolated-agent}.
13
+ * discipline to {@link ../isolated-agent}.
14
14
  */
15
15
 
16
- import { mkdir, appendFile } from "node:fs/promises";
17
16
  import { dirs } from "../../../util/paths.js";
18
17
  import { log, logWarn } from "../../../util/log.js";
19
18
  import {
@@ -22,12 +21,12 @@ import {
22
21
  } from "../../engine/backend-controller/index.js";
23
22
  import { taskTable } from "../../tasks/index.js";
24
23
  import type { OneShotAgentParams } from "../../types.js";
25
- import { runIsolatedAgent } from "./isolated-agent.js";
24
+ import { runIsolatedAgent } from "../isolated-agent.js";
25
+ import { openRunLog } from "../run-log.js";
26
26
  import {
27
27
  buildJobSystemPrompt,
28
28
  jobLogPath,
29
29
  JOB_CONTEXT_LABEL,
30
- JOB_LOGS_DIR,
31
30
  type JobKind,
32
31
  } from "./job-prompt.js";
33
32
 
@@ -70,22 +69,17 @@ export type JobOneShotResult =
70
69
  { status: "ran" } | { status: "skipped"; reason: string };
71
70
 
72
71
  /** Open a per-run log file and return an appender bound to it. */
73
- async function openJobLog(
72
+ function openJobLog(
74
73
  kind: JobKind,
75
74
  label: string,
76
75
  backendId: string,
77
76
  model: string,
78
77
  ): Promise<(text: string) => Promise<void>> {
79
- await mkdir(JOB_LOGS_DIR, { recursive: true }).catch(() => {});
80
- const file = jobLogPath(kind, label);
81
- const appendLog = async (text: string) => {
82
- await appendFile(file, text).catch(() => {});
83
- };
84
- await appendLog(
78
+ return openRunLog(
79
+ jobLogPath(kind, label),
85
80
  `# ${kind} job "${label}" — ${new Date().toISOString()}\n` +
86
81
  `**Backend:** ${backendId} **Model:** ${model}\n\n`,
87
82
  );
88
- return appendLog;
89
83
  }
90
84
 
91
85
  // The warning is deferred to runJobOneShot: an attempt that fails here may
@@ -182,6 +176,7 @@ async function attemptJobOneShot(
182
176
  background,
183
177
  params: oneShot,
184
178
  timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
179
+ logCategory: params.kind === "cron" ? "cron" : "triggers",
185
180
  // No evictLabel: the job context label is shared with heartbeat, so a
186
181
  // sweep here could kill a concurrent heartbeat's subprocess. Bounded
187
182
  // abort-grace is enough.
@@ -10,7 +10,7 @@ import { dirs } from "../../../util/paths.js";
10
10
  export type JobKind = "trigger" | "cron";
11
11
 
12
12
  /** Where job run logs live. */
13
- export const JOB_LOGS_DIR = resolve(dirs.logs, "jobs");
13
+ const JOB_LOGS_DIR = resolve(dirs.logs, "jobs");
14
14
 
15
15
  /**
16
16
  * The one context label every backend wires for the full outbound frontend tool