@netnodeag/kraftwerk 0.80.0 → 0.82.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 (84) hide show
  1. package/README.md +57 -2
  2. package/dist/cli/boards.d.ts +2 -0
  3. package/dist/cli/boards.js +486 -0
  4. package/dist/cli/init.js +4 -2
  5. package/dist/cli/kraftwerk.js +6 -0
  6. package/dist/cli/temp.d.ts +2 -0
  7. package/dist/cli/temp.js +62 -0
  8. package/dist/client/routes.d.ts +16 -0
  9. package/dist/client/routes.js +16 -0
  10. package/dist/config.d.ts +19 -0
  11. package/dist/config.js +24 -1
  12. package/dist/core/attention.d.ts +2 -2
  13. package/dist/core/attention.js +22 -2
  14. package/dist/core/board-wake.d.ts +21 -0
  15. package/dist/core/board-wake.js +221 -0
  16. package/dist/core/boards.d.ts +256 -0
  17. package/dist/core/boards.js +936 -0
  18. package/dist/core/chat/acp.d.ts +1 -1
  19. package/dist/core/chat/acp.js +3 -1
  20. package/dist/core/chat/backend.d.ts +13 -0
  21. package/dist/core/chat/backend.js +9 -1
  22. package/dist/core/chat/context.js +26 -9
  23. package/dist/core/chat/pi.d.ts +1 -1
  24. package/dist/core/chat/pi.js +3 -1
  25. package/dist/core/chat/seats.js +6 -2
  26. package/dist/core/chat/sessions.d.ts +2 -0
  27. package/dist/core/chat/sessions.js +4 -0
  28. package/dist/core/chat/state.js +1 -1
  29. package/dist/core/chat/types.d.ts +11 -1
  30. package/dist/core/files.d.ts +1 -1
  31. package/dist/core/files.js +20 -1
  32. package/dist/core/git.js +3 -1
  33. package/dist/core/notifications.d.ts +1 -1
  34. package/dist/core/projects.js +1 -1
  35. package/dist/core/refs.d.ts +1 -1
  36. package/dist/core/refs.js +25 -1
  37. package/dist/core/search.d.ts +12 -0
  38. package/dist/core/search.js +19 -0
  39. package/dist/core/settings.d.ts +2 -0
  40. package/dist/core/settings.js +13 -1
  41. package/dist/core/temp.d.ts +73 -0
  42. package/dist/core/temp.js +154 -0
  43. package/dist/core/trash.d.ts +1 -1
  44. package/dist/harness.d.ts +2 -0
  45. package/dist/harnesses/acp.js +1 -1
  46. package/dist/harnesses/claude.js +1 -0
  47. package/dist/harnesses/codex.js +1 -0
  48. package/dist/harnesses/pi.js +1 -0
  49. package/dist/run.d.ts +7 -0
  50. package/dist/run.js +13 -1
  51. package/dist/server/api/boards.d.ts +105 -0
  52. package/dist/server/api/boards.js +160 -0
  53. package/dist/server/api/chats.d.ts +2 -0
  54. package/dist/server/api/chats.js +9 -3
  55. package/dist/server/api/files.d.ts +9 -1
  56. package/dist/server/api/files.js +13 -0
  57. package/dist/server/api/index.d.ts +113 -2
  58. package/dist/server/api/index.js +2 -0
  59. package/dist/server/api/workspace.d.ts +5 -0
  60. package/dist/server/api/workspace.js +2 -0
  61. package/dist/server/push.js +1 -1
  62. package/dist/server/server.js +3 -0
  63. package/dist/yaml.js +1 -0
  64. package/package.json +1 -1
  65. package/schema/kraftwerk.schema.json +39 -0
  66. package/web/dist/assets/{dist-DlZPeneT.js → dist-BBFUIftm.js} +1 -1
  67. package/web/dist/assets/{dist-u_oAKIhZ.js → dist-Bie1ebP9.js} +1 -1
  68. package/web/dist/assets/{dist-NX4_yvho.js → dist-BuwdjX6z.js} +1 -1
  69. package/web/dist/assets/{dist-1PajaYPg.js → dist-C0ve4Tka.js} +1 -1
  70. package/web/dist/assets/{dist-DgKgeYFJ.js → dist-C16bXf8c.js} +1 -1
  71. package/web/dist/assets/{dist-DKduisGW.js → dist-CDUZdAAS.js} +1 -1
  72. package/web/dist/assets/{dist-B4dqwo7_.js → dist-CH73jsgO.js} +1 -1
  73. package/web/dist/assets/{dist-D-f0nszo.js → dist-C_rJjRj7.js} +1 -1
  74. package/web/dist/assets/dist-DYICNp09.js +1 -0
  75. package/web/dist/assets/{dist-K_3Y5Nbl.js → dist-DtUB2Uu2.js} +1 -1
  76. package/web/dist/assets/{dist-CK2u1OXb.js → dist-DvkrCSGL.js} +1 -1
  77. package/web/dist/assets/{editor-CE9TlKkJ.js → editor-3z92i0tV.js} +3 -3
  78. package/web/dist/assets/{editor-CZMrhTTL.css → editor-C0_Snnzk.css} +1 -1
  79. package/web/dist/assets/index-DRhNELK_.js +232 -0
  80. package/web/dist/assets/index-unGEux3Y.css +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/dist-Bp1iMuQ7.js +0 -1
  83. package/web/dist/assets/index-Dvt-vE1h.css +0 -2
  84. package/web/dist/assets/index-IAuwD22j.js +0 -231
@@ -0,0 +1,62 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import { cleanTemp, listTemp, SWEEP_AFTER_MS } from "../core/temp.js";
4
+ import { fmtAgo } from "./workspaces.js";
5
+ import { initContext as prepare } from "./routines.js";
6
+ /**
7
+ * `kraftwerk temp` — the sessions' scratch folders under kraftwerk-data/temp
8
+ * (one per chat session; TMPDIR of the agent process). Nothing in there is
9
+ * a deliverable: a clean deletes for good, no trash.
10
+ *
11
+ * kraftwerk temp list: session, files, size, last change
12
+ * kraftwerk temp clean remove folders idle for a week
13
+ * kraftwerk temp clean --older-than <d> … idle for <d> days
14
+ * kraftwerk temp clean --all remove every folder
15
+ */
16
+ const fmtBytes = (n) => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MB` : `${(n / 1024 ** 3).toFixed(2)} GB`;
17
+ export function registerTempCommands(program) {
18
+ const temp = program.command("temp").description("The sessions' scratch folders: list, clean");
19
+ temp
20
+ .command("list", { isDefault: true })
21
+ .description("List the scratch folders, newest first")
22
+ .option("--json", "Machine-readable output")
23
+ .action(async (opts) => {
24
+ await prepare();
25
+ const view = await listTemp();
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(view, null, 2));
28
+ return;
29
+ }
30
+ if (view.entries.length === 0) {
31
+ console.log(chalk.dim("No scratch folders."));
32
+ console.log(chalk.dim(view.root));
33
+ return;
34
+ }
35
+ const table = new Table({ head: ["session", "files", "size", "last change"].map((h) => chalk.bold(h)) });
36
+ for (const e of view.entries)
37
+ table.push([chalk.cyan(e.id), String(e.files), fmtBytes(e.bytes), chalk.dim(fmtAgo(e.modifiedAt))]);
38
+ console.log(table.toString());
39
+ console.log(chalk.dim(`${view.root} — ${fmtBytes(view.bytes)} in ${view.entries.length} folder${view.entries.length === 1 ? "" : "s"}`));
40
+ });
41
+ temp
42
+ .command("clean")
43
+ .description(`Remove scratch folders idle for ${SWEEP_AFTER_MS / 86_400_000} days (for good — nothing there is a deliverable)`)
44
+ .option("--older-than <days>", "Remove folders idle for this many days instead")
45
+ .option("--all", "Remove every folder, whatever its age")
46
+ .option("--json", "Machine-readable output")
47
+ .action(async (opts) => {
48
+ await prepare();
49
+ const days = opts.olderThan === undefined ? undefined : Number(opts.olderThan);
50
+ if (days !== undefined && (!Number.isFinite(days) || days < 0)) {
51
+ console.error(chalk.red(`--older-than wants a number of days, not "${opts.olderThan}"`));
52
+ process.exit(2);
53
+ }
54
+ const gone = await cleanTemp({ all: opts.all, ...(days === undefined ? {} : { olderThanMs: days * 86_400_000 }) });
55
+ const bytes = gone.reduce((n, e) => n + e.bytes, 0);
56
+ if (opts.json) {
57
+ console.log(JSON.stringify({ removed: gone.map((e) => e.id), bytes }, null, 2));
58
+ return;
59
+ }
60
+ console.log(`${chalk.green("✔")} removed ${gone.length} folder${gone.length === 1 ? "" : "s"} (${fmtBytes(bytes)})`);
61
+ });
62
+ }
@@ -20,6 +20,12 @@ export declare const ROUTES: {
20
20
  readonly "agents.skills": readonly ["GET", "/api/agents/:slug/skills"];
21
21
  readonly "agents.status": readonly ["GET", "/api/agent-status"];
22
22
  readonly "attention.list": readonly ["GET", "/api/attention"];
23
+ readonly "boards.create": readonly ["POST", "/api/boards"];
24
+ readonly "boards.delete": readonly ["DELETE", "/api/boards/:board"];
25
+ readonly "boards.ensure": readonly ["POST", "/api/boards/default"];
26
+ readonly "boards.get": readonly ["GET", "/api/boards/:board"];
27
+ readonly "boards.list": readonly ["GET", "/api/boards"];
28
+ readonly "boards.save": readonly ["PUT", "/api/boards/:board"];
23
29
  readonly "channels.create": readonly ["POST", "/api/channels"];
24
30
  readonly "channels.delete": readonly ["DELETE", "/api/channels/:slug"];
25
31
  readonly "channels.fromChat": readonly ["POST", "/api/channels/from-chat"];
@@ -136,6 +142,16 @@ export declare const ROUTES: {
136
142
  readonly "settings.save": readonly ["PUT", "/api/settings"];
137
143
  readonly "skills.get": readonly ["GET", "/api/skills/:name"];
138
144
  readonly "skills.list": readonly ["GET", "/api/skills"];
145
+ readonly "tasks.assign": readonly ["POST", "/api/boards/:board/tasks/:task/assign"];
146
+ readonly "tasks.create": readonly ["POST", "/api/boards/:board/tasks"];
147
+ readonly "tasks.delete": readonly ["DELETE", "/api/boards/:board/tasks/:task"];
148
+ readonly "tasks.get": readonly ["GET", "/api/boards/:board/tasks/:task"];
149
+ readonly "tasks.log": readonly ["POST", "/api/boards/:board/tasks/:task/log"];
150
+ readonly "tasks.move": readonly ["POST", "/api/boards/:board/tasks/:task/move"];
151
+ readonly "tasks.pick": readonly ["POST", "/api/boards/:board/pick"];
152
+ readonly "tasks.save": readonly ["PUT", "/api/boards/:board/tasks/:task"];
153
+ readonly "temp.clean": readonly ["DELETE", "/api/temp"];
154
+ readonly "temp.list": readonly ["GET", "/api/temp"];
139
155
  readonly "trash.empty": readonly ["DELETE", "/api/trash"];
140
156
  readonly "trash.list": readonly ["GET", "/api/trash"];
141
157
  readonly "trash.purge": readonly ["POST", "/api/trash/purge"];
@@ -21,6 +21,12 @@ export const ROUTES = {
21
21
  "agents.skills": ["GET", "/api/agents/:slug/skills"],
22
22
  "agents.status": ["GET", "/api/agent-status"],
23
23
  "attention.list": ["GET", "/api/attention"],
24
+ "boards.create": ["POST", "/api/boards"],
25
+ "boards.delete": ["DELETE", "/api/boards/:board"],
26
+ "boards.ensure": ["POST", "/api/boards/default"],
27
+ "boards.get": ["GET", "/api/boards/:board"],
28
+ "boards.list": ["GET", "/api/boards"],
29
+ "boards.save": ["PUT", "/api/boards/:board"],
24
30
  "channels.create": ["POST", "/api/channels"],
25
31
  "channels.delete": ["DELETE", "/api/channels/:slug"],
26
32
  "channels.fromChat": ["POST", "/api/channels/from-chat"],
@@ -137,6 +143,16 @@ export const ROUTES = {
137
143
  "settings.save": ["PUT", "/api/settings"],
138
144
  "skills.get": ["GET", "/api/skills/:name"],
139
145
  "skills.list": ["GET", "/api/skills"],
146
+ "tasks.assign": ["POST", "/api/boards/:board/tasks/:task/assign"],
147
+ "tasks.create": ["POST", "/api/boards/:board/tasks"],
148
+ "tasks.delete": ["DELETE", "/api/boards/:board/tasks/:task"],
149
+ "tasks.get": ["GET", "/api/boards/:board/tasks/:task"],
150
+ "tasks.log": ["POST", "/api/boards/:board/tasks/:task/log"],
151
+ "tasks.move": ["POST", "/api/boards/:board/tasks/:task/move"],
152
+ "tasks.pick": ["POST", "/api/boards/:board/pick"],
153
+ "tasks.save": ["PUT", "/api/boards/:board/tasks/:task"],
154
+ "temp.clean": ["DELETE", "/api/temp"],
155
+ "temp.list": ["GET", "/api/temp"],
140
156
  "trash.empty": ["DELETE", "/api/trash"],
141
157
  "trash.list": ["GET", "/api/trash"],
142
158
  "trash.purge": ["POST", "/api/trash/purge"],
package/dist/config.d.ts CHANGED
@@ -135,6 +135,23 @@ export interface EntitiesConfig {
135
135
  root?: string;
136
136
  }
137
137
  export declare const ENTITY_KINDS: readonly ["contacts", "places", "events"];
138
+ /**
139
+ * Boards: the work as columns of tasks, one folder per task (task.md, log.md,
140
+ * files) under a board folder — inside a project (projects/<p>/boards/) or
141
+ * at the workspace level under the boards root. Part of the workspace and
142
+ * synced by the workspace git; agents are woken by events on this machine.
143
+ */
144
+ export interface BoardsConfig {
145
+ /** false keeps the block but turns the feature off. Default: true. */
146
+ enabled?: boolean;
147
+ /** Where the workspace boards live, relative to the project root. Default: kraftwerk-data/boards */
148
+ root?: string;
149
+ /** Default columns for boards that name none (slugs or {slug, label, limit, done, review}). Default: draft, todo, progress, review, done. */
150
+ columns?: unknown;
151
+ }
152
+ export declare const BOARDS_DEFAULT_ROOT = "kraftwerk-data/boards";
153
+ /** Absolute workspace boards root when the feature is on, undefined otherwise. */
154
+ export declare function boardsRootFor(project: ResolvedWorkspace): string | undefined;
138
155
  export type EntityKind = (typeof ENTITY_KINDS)[number];
139
156
  /** Where a kind's files live when its `root` is not set. */
140
157
  export declare const entityDefaultRoot: (kind: EntityKind) => string;
@@ -218,6 +235,8 @@ export interface WorkspaceConfig {
218
235
  places?: EntitiesConfig;
219
236
  /** Events: dated entries (one-off), one Markdown file each. Absent = off. */
220
237
  events?: EntitiesConfig;
238
+ /** Boards: tasks as folders in columns, per project and workspace-wide. Absent = off. */
239
+ boards?: BoardsConfig;
221
240
  /** The machine's cloud record lists this workspace. Absent = listed; `enabled: false` leaves it out. */
222
241
  cloud?: CloudConfig;
223
242
  }
package/dist/config.js CHANGED
@@ -97,6 +97,14 @@ export function projectsRootFor(project) {
97
97
  return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
98
98
  }
99
99
  export const ENTITY_KINDS = ["contacts", "places", "events"];
100
+ export const BOARDS_DEFAULT_ROOT = "kraftwerk-data/boards";
101
+ /** Absolute workspace boards root when the feature is on, undefined otherwise. */
102
+ export function boardsRootFor(project) {
103
+ const b = project.config.boards;
104
+ if (!b || b.enabled === false)
105
+ return undefined;
106
+ return path.resolve(project.root, b.root ?? BOARDS_DEFAULT_ROOT);
107
+ }
100
108
  /** Where a kind's files live when its `root` is not set. */
101
109
  export const entityDefaultRoot = (kind) => `kraftwerk-data/${kind}`;
102
110
  /** Absolute root of a kind when its feature is on, undefined otherwise. */
@@ -241,7 +249,7 @@ export async function addWorkspaceId(configPath) {
241
249
  await writeFile(configPath, out);
242
250
  return id;
243
251
  }
244
- const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "contacts", "places", "events", "cloud"];
252
+ const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "contacts", "places", "events", "boards", "cloud"];
245
253
  /**
246
254
  * Keys an older kraftwerk understood and this one ignores, so the files
247
255
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -313,6 +321,11 @@ async function loadConfig(configPath) {
313
321
  config[key] = {};
314
322
  validateRootBlock(configPath, key, config[key]);
315
323
  }
324
+ else if (key === "boards") {
325
+ if (config[key] === null)
326
+ config[key] = {};
327
+ validateBoards(configPath, config[key]);
328
+ }
316
329
  else if (key === "cloud") {
317
330
  if (config[key] === null)
318
331
  config[key] = {};
@@ -418,6 +431,16 @@ function validateGit(configPath, value) {
418
431
  * anything outside it — it is what remove/delete operate under, and what
419
432
  * the workspace git may stage.
420
433
  */
434
+ /** `boards`: the root block plus `columns`, which core/boards.ts reads (a bad list falls back to the defaults there). */
435
+ function validateBoards(configPath, value) {
436
+ const file = path.basename(configPath);
437
+ if (typeof value !== "object" || value === null || Array.isArray(value))
438
+ throw new Error(`${file}: boards must be a mapping (enabled, root, columns)`);
439
+ const { columns, ...rest } = value;
440
+ if (columns !== undefined && columns !== null && !Array.isArray(columns))
441
+ throw new Error(`${file}: boards.columns must be a list of columns`);
442
+ validateRootBlock(configPath, "boards", rest);
443
+ }
421
444
  function validateRootBlock(configPath, block, value) {
422
445
  const file = path.basename(configPath);
423
446
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -33,8 +33,8 @@ export interface AttentionOwner {
33
33
  export interface AttentionItem {
34
34
  /** The request id (live requests) or the notification id (failures). */
35
35
  id: string;
36
- /** runner: routines without a runner, or naming a retired identity of this machine (see routines.ts). */
37
- kind: "approval" | "question" | "failed" | "runner";
36
+ /** runner: routines without a runner, or naming a retired identity of this machine (see routines.ts); review: a task waits in a review column (boards.ts). */
37
+ kind: "approval" | "question" | "failed" | "runner" | "review";
38
38
  /** What is asked, or what went wrong. */
39
39
  title: string;
40
40
  /** The session it sits in. */
@@ -1,14 +1,16 @@
1
1
  import { listAgents } from "./agents.js";
2
+ import { getBoard, listAllBoards } from "./boards.js";
2
3
  import { waitingRequests } from "./chat/sessions.js";
3
4
  import { chatHref, listNotifications } from "./notifications.js";
4
5
  import { listProjects } from "./projects.js";
5
6
  import { runnerFindings } from "./routines.js";
6
7
  export async function listAttention() {
7
- const [agents, projects, notes, runners] = await Promise.all([
8
+ const [agents, projects, notes, runners, boards] = await Promise.all([
8
9
  listAgents().catch(() => []),
9
10
  listProjects().catch(() => ({ projects: [] })),
10
11
  listNotifications().catch(() => ({ items: [] })),
11
12
  runnerFindings().catch(() => ({ unassigned: [], retired: [] })),
13
+ listAllBoards().catch(() => []),
12
14
  ]);
13
15
  const agentOf = (slug) => {
14
16
  const a = agents.find((x) => x.slug === slug);
@@ -101,6 +103,24 @@ export async function listAttention() {
101
103
  routines: these,
102
104
  });
103
105
  }
104
- const items = [...waiting, ...failed, ...runner].sort((a, b) => a.since.localeCompare(b.since));
106
+ // Tasks in a review column: the agents are done, a human decides. One item per task, placed in its project (workspace boards: general).
107
+ const review = [];
108
+ for (const b of boards) {
109
+ const detail = await getBoard(b).catch(() => null);
110
+ if (!detail)
111
+ continue;
112
+ const reviewCols = new Set(detail.columns.filter((c) => c.review).map((c) => c.slug));
113
+ for (const t of detail.items.filter((t) => reviewCols.has(t.status))) {
114
+ review.push({
115
+ id: `review:${t.ref}`,
116
+ kind: "review",
117
+ title: `${t.number} ${t.title} waits for your review${t.lastLog ? ` — ${t.lastLog}` : ""}`,
118
+ href: b.project ? `/projects/${b.project}/boards/${b.board}/${t.slug}` : `/boards/${b.board}/${t.slug}`,
119
+ since: t.updatedAt ?? new Date(0).toISOString(),
120
+ owner: b.project ? { project: projectOf(b.project) } : { general: true },
121
+ });
122
+ }
123
+ }
124
+ const items = [...waiting, ...failed, ...runner, ...review].sort((a, b) => a.since.localeCompare(b.since));
105
125
  return { items };
106
126
  }
@@ -0,0 +1,21 @@
1
+ /** A chat on the task page: the agent's task session (scope.task) or a chat tagged with the task (meta.task, "work on this"). */
2
+ export interface TaskSession {
3
+ id: string;
4
+ /** The agent slug of a task session; "claude" | "codex" | "pi" for a plain chat tagged with the task. */
5
+ agent: string;
6
+ /** The agent's display name and emoji when it is one of the workspace's agents. */
7
+ name?: string;
8
+ emoji?: string;
9
+ title: string;
10
+ updatedAt: string;
11
+ busy: boolean;
12
+ /** The chat's route in the UI (#/agents/<slug>/chat/<id>, #/projects/<p>/chat/<id>, …). */
13
+ href: string;
14
+ }
15
+ /** The sessions that worked on a task, newest first: what the task page links the log's actors to. */
16
+ export declare function taskSessions(ref: string): Promise<TaskSession[]>;
17
+ /** Consume what was appended since the last run. Serialised: a run that overlaps is queued, never doubled. */
18
+ export declare function consumeTaskEvents(): Promise<number>;
19
+ /** Start consuming the current workspace's task events: on every change signal, on file changes, and every FALLBACK_MS anyway. */
20
+ export declare function startBoardWake(): void;
21
+ export declare function stopBoardWake(): void;
@@ -0,0 +1,221 @@
1
+ import { promises as fs, watch } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgent, listAgents } from "./agents.js";
4
+ import { EVENTS_FILE } from "./boards.js";
5
+ import { onChanged } from "./changes.js";
6
+ import { createChat, listChats, postMessage } from "./chat/sessions.js";
7
+ import { getOutputDir } from "./context.js";
8
+ import { chatHref, dismissKey, pushNotification } from "./notifications.js";
9
+ import { perWorkspace, workspaceOpen } from "./workspace.js";
10
+ /**
11
+ * Waking agents for their tasks. boards.ts appends a TaskEvent line to
12
+ * <output>/boards/events.jsonl whenever a task with an agent as assignee
13
+ * sits in a work column after a change (created there, moved there,
14
+ * assigned); this module, started by the server for every open workspace,
15
+ * consumes the file: for each event it opens a session for the agent with
16
+ * the task as its scope (ChatScope.task → the task block in its context,
17
+ * unattended like a routine run) and posts why it was woken — or, when the
18
+ * agent already has a live session for that task, posts into that one.
19
+ *
20
+ * The file is read past a remembered offset (<output>/boards/events.offset),
21
+ * so every line is acted on once; it is rotated when it grows large. Writers
22
+ * append whole lines (O_APPEND), so a half-written line never appears.
23
+ */
24
+ const STATE_FILE = "boards/events.offset";
25
+ const ROTATE_OVER = 256 * 1024;
26
+ const FALLBACK_MS = 20_000;
27
+ const consumer = perWorkspace(() => ({
28
+ timer: null,
29
+ watcher: null,
30
+ unsubscribe: null,
31
+ running: false,
32
+ again: false,
33
+ }));
34
+ const eventsFile = () => path.join(getOutputDir(), EVENTS_FILE);
35
+ const offsetFile = () => path.join(getOutputDir(), STATE_FILE);
36
+ async function readOffset() {
37
+ const n = Number(await fs.readFile(offsetFile(), "utf8").catch(() => "0"));
38
+ return Number.isFinite(n) && n >= 0 ? n : 0;
39
+ }
40
+ /** Lines appended since the offset; the new offset. */
41
+ async function readNew() {
42
+ const file = eventsFile();
43
+ const st = await fs.stat(file).catch(() => null);
44
+ if (!st)
45
+ return { events: [], offset: 0, size: 0 };
46
+ let offset = await readOffset();
47
+ if (offset > st.size)
48
+ offset = 0; // the file was rotated or replaced
49
+ if (offset === st.size)
50
+ return { events: [], offset, size: st.size };
51
+ const fh = await fs.open(file, "r");
52
+ try {
53
+ const buf = Buffer.alloc(st.size - offset);
54
+ await fh.read(buf, 0, buf.length, offset);
55
+ const text = buf.toString("utf8");
56
+ const complete = text.lastIndexOf("\n");
57
+ if (complete < 0)
58
+ return { events: [], offset, size: st.size };
59
+ const events = [];
60
+ for (const line of text.slice(0, complete).split("\n")) {
61
+ if (!line.trim())
62
+ continue;
63
+ try {
64
+ events.push(JSON.parse(line));
65
+ }
66
+ catch {
67
+ /* a broken line is skipped, not retried forever */
68
+ }
69
+ }
70
+ return { events, offset: offset + complete + 1, size: st.size };
71
+ }
72
+ finally {
73
+ await fh.close();
74
+ }
75
+ }
76
+ /** Keep the file small: once everything is consumed and it is big, start over (a writer racing the rename lands in the new file). */
77
+ async function rotate(offset, size) {
78
+ if (size < ROTATE_OVER || offset < size)
79
+ return;
80
+ await fs.rename(eventsFile(), `${eventsFile()}.old`).catch(() => { });
81
+ await fs.rm(`${eventsFile()}.old`, { force: true }).catch(() => { });
82
+ await fs.writeFile(offsetFile(), "0");
83
+ }
84
+ /** The message the agent gets: why it was woken, in one paragraph; the task itself is in its context block. */
85
+ function wakeMessage(ev, resumed) {
86
+ const who = ev.actor.replace(/^human:/, "");
87
+ const why = ev.reason === "created"
88
+ ? `Task ${ev.task} ("${ev.title}") was created for you by ${who}`
89
+ : ev.reason === "assigned"
90
+ ? `Task ${ev.task} ("${ev.title}") was handed to you by ${who}`
91
+ : `Task ${ev.task} ("${ev.title}") was moved to ${ev.status} for you by ${who}`;
92
+ return (`${why}${ev.message ? `: ${ev.message}` : "."}\n` +
93
+ (resumed
94
+ ? `Continue with it: read the task's log for what changed (\`npx kraftwerk tasks show ${ev.task} ${cliFlags(ev)}\`).`
95
+ : `It is in ${ev.status}. Read the task block in your context (task.md, files, log), move it to the work column when you start, log what you do, and hand it to review when done.`));
96
+ }
97
+ const cliFlags = (ev) => (ev.project ? `--project ${ev.project} --board ${ev.board}` : `--board ${ev.board}`);
98
+ const taskOf = (c) => c.task ?? (c.scope.kind === "agent" ? c.scope.task : undefined);
99
+ /** The sessions that worked on a task, newest first: what the task page links the log's actors to. */
100
+ export async function taskSessions(ref) {
101
+ const [chats, agents] = await Promise.all([listChats(), listAgents().catch(() => [])]);
102
+ return chats
103
+ .filter((c) => taskOf(c) === ref)
104
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
105
+ .map((c) => {
106
+ const slug = c.scope.kind === "agent" ? c.scope.slug : c.agent;
107
+ const def = agents.find((a) => a.slug === slug);
108
+ return { id: c.id, agent: slug, ...(def ? { name: def.name, emoji: def.emoji } : {}), title: c.title, updatedAt: c.updatedAt, busy: c.busy, href: chatHref(c) };
109
+ });
110
+ }
111
+ /** One event: post into the agent's live session for this task, or open one. */
112
+ async function wake(ev) {
113
+ const def = await getAgent(ev.agent);
114
+ if (!def)
115
+ throw new Error(`agent "${ev.agent}" not found`);
116
+ const existing = (await listChats()).find((c) => c.scope.kind === "agent" && c.scope.slug === ev.agent && c.scope.task === ev.ref);
117
+ // Already on it (a turn is running in its session for this task): the agent reads the log when it looks; no second session.
118
+ if (existing?.busy)
119
+ return;
120
+ const meta = existing ??
121
+ (await createChat({
122
+ agent: def.harness,
123
+ scope: { kind: "agent", slug: ev.agent, task: ev.ref },
124
+ title: `📋 ${ev.task}`,
125
+ ...(ev.project ? { project: ev.project } : {}),
126
+ }));
127
+ const { error } = await postMessage(meta.id, wakeMessage(ev, !!existing));
128
+ if (error)
129
+ throw new Error(error);
130
+ await dismissKey(`task-wake:${ev.ref}`);
131
+ await pushNotification({
132
+ kind: "task_woken",
133
+ title: `📋 ${ev.task} → ${def.emoji} ${def.name}`,
134
+ body: ev.message ?? ev.title,
135
+ href: chatHref(meta),
136
+ key: `task-woken:${ev.ref}:${ev.agent}`,
137
+ }).catch(() => { });
138
+ }
139
+ /** Consume what was appended since the last run. Serialised: a run that overlaps is queued, never doubled. */
140
+ export async function consumeTaskEvents() {
141
+ const c = consumer();
142
+ if (c.running) {
143
+ c.again = true;
144
+ return 0;
145
+ }
146
+ c.running = true;
147
+ let handled = 0;
148
+ try {
149
+ const { events, offset, size } = await readNew();
150
+ if (events.length) {
151
+ // The offset moves first: an event that keeps failing must not be retried on every tick.
152
+ await fs.mkdir(path.dirname(offsetFile()), { recursive: true });
153
+ await fs.writeFile(offsetFile(), String(offset));
154
+ // The same task for the same agent twice in one batch (assign, then move): the last reason wins.
155
+ const latest = new Map();
156
+ for (const ev of events)
157
+ latest.set(`${ev.ref}:${ev.agent}`, ev);
158
+ for (const ev of latest.values()) {
159
+ try {
160
+ await wake(ev);
161
+ handled++;
162
+ }
163
+ catch (err) {
164
+ await pushNotification({
165
+ kind: "task_wake_failed",
166
+ title: `📋 ${ev.task}: ${ev.agent} could not be started`,
167
+ body: err.message,
168
+ href: ev.project ? `/projects/${ev.project}/boards/${ev.board}/${ev.task}` : `/boards/${ev.board}/${ev.task}`,
169
+ key: `task-wake:${ev.ref}`,
170
+ }).catch(() => { });
171
+ }
172
+ }
173
+ }
174
+ await rotate(offset, size);
175
+ }
176
+ finally {
177
+ c.running = false;
178
+ if (c.again) {
179
+ c.again = false;
180
+ void consumeTaskEvents().catch(() => { });
181
+ }
182
+ }
183
+ return handled;
184
+ }
185
+ /** Start consuming the current workspace's task events: on every change signal, on file changes, and every FALLBACK_MS anyway. */
186
+ export function startBoardWake() {
187
+ const c = consumer();
188
+ if (c.timer)
189
+ return;
190
+ const kick = () => {
191
+ if (!workspaceOpen())
192
+ return;
193
+ void consumeTaskEvents().catch(() => { });
194
+ };
195
+ c.timer = setInterval(kick, FALLBACK_MS);
196
+ c.timer.unref?.();
197
+ c.unsubscribe = onChanged(kick);
198
+ const dir = path.dirname(eventsFile());
199
+ void fs.mkdir(dir, { recursive: true }).then(() => {
200
+ if (!c.timer || c.watcher)
201
+ return;
202
+ try {
203
+ c.watcher = watch(dir, { persistent: false }, kick);
204
+ c.watcher.on("error", () => { });
205
+ }
206
+ catch {
207
+ /* no watcher: the interval still runs */
208
+ }
209
+ });
210
+ kick();
211
+ }
212
+ export function stopBoardWake() {
213
+ const c = consumer();
214
+ if (c.timer)
215
+ clearInterval(c.timer);
216
+ c.timer = null;
217
+ c.unsubscribe?.();
218
+ c.unsubscribe = null;
219
+ c.watcher?.close();
220
+ c.watcher = null;
221
+ }