@netnodeag/kraftwerk 0.61.0 → 0.63.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 (75) hide show
  1. package/dist/cli/routines.js +2 -3
  2. package/dist/client/index.d.ts +59 -0
  3. package/dist/client/index.js +63 -0
  4. package/dist/client/live.d.ts +34 -0
  5. package/dist/client/live.js +169 -0
  6. package/dist/client/routes.d.ts +125 -0
  7. package/dist/client/routes.js +126 -0
  8. package/dist/inspector/api/agents.d.ts +31 -0
  9. package/dist/inspector/api/agents.js +50 -0
  10. package/dist/inspector/api/attention.d.ts +10 -0
  11. package/dist/inspector/api/attention.js +23 -0
  12. package/dist/inspector/api/channels.d.ts +67 -0
  13. package/dist/inspector/api/channels.js +58 -0
  14. package/dist/inspector/api/chats.d.ts +55 -0
  15. package/dist/inspector/api/chats.js +154 -0
  16. package/dist/inspector/api/files.d.ts +15 -0
  17. package/dist/inspector/api/files.js +43 -0
  18. package/dist/inspector/api/git.d.ts +25 -0
  19. package/dist/inspector/api/git.js +56 -0
  20. package/dist/inspector/api/index.d.ts +334 -0
  21. package/dist/inspector/api/index.js +43 -0
  22. package/dist/inspector/api/knowledge.d.ts +13 -0
  23. package/dist/inspector/api/knowledge.js +56 -0
  24. package/dist/inspector/api/mime.d.ts +2 -0
  25. package/dist/inspector/api/mime.js +18 -0
  26. package/dist/inspector/api/projects.d.ts +11 -0
  27. package/dist/inspector/api/projects.js +32 -0
  28. package/dist/inspector/api/router.d.ts +75 -0
  29. package/dist/inspector/api/router.js +186 -0
  30. package/dist/inspector/api/runs.d.ts +33 -0
  31. package/dist/inspector/api/runs.js +105 -0
  32. package/dist/inspector/api/socket.d.ts +3 -0
  33. package/dist/inspector/api/socket.js +115 -0
  34. package/dist/inspector/api/vibeables.d.ts +4 -0
  35. package/dist/inspector/api/vibeables.js +31 -0
  36. package/dist/inspector/api/workspace.d.ts +55 -0
  37. package/dist/inspector/api/workspace.js +117 -0
  38. package/dist/inspector/chat/sessions.js +31 -19
  39. package/dist/inspector/context.d.ts +5 -7
  40. package/dist/inspector/context.js +6 -20
  41. package/dist/inspector/git.js +52 -43
  42. package/dist/inspector/live.d.ts +42 -0
  43. package/dist/inspector/live.js +91 -0
  44. package/dist/inspector/notifications.js +19 -13
  45. package/dist/inspector/routines.d.ts +22 -1
  46. package/dist/inspector/routines.js +32 -5
  47. package/dist/inspector/runner.js +5 -4
  48. package/dist/inspector/server.d.ts +2 -2
  49. package/dist/inspector/server.js +59 -1389
  50. package/dist/inspector/version.d.ts +9 -0
  51. package/dist/inspector/version.js +27 -0
  52. package/dist/inspector/vibeables.js +25 -23
  53. package/dist/inspector/workspace.d.ts +42 -0
  54. package/dist/inspector/workspace.js +63 -0
  55. package/dist/inspector/ws.d.ts +11 -0
  56. package/dist/inspector/ws.js +143 -0
  57. package/inspector/dist/assets/{dist-B3L4wDm3.js → dist-9RESajfo.js} +1 -1
  58. package/inspector/dist/assets/{dist-Cwukoo61.js → dist-Bm5S1BoZ.js} +1 -1
  59. package/inspector/dist/assets/dist-C8BofvHx.js +1 -0
  60. package/inspector/dist/assets/{dist-wYE0FpHk.js → dist-CIs4wnTu.js} +1 -1
  61. package/inspector/dist/assets/{dist-De7-pK5i.js → dist-CjuETd-_.js} +1 -1
  62. package/inspector/dist/assets/{dist-B58fOC3R.js → dist-CmVK6rgN.js} +1 -1
  63. package/inspector/dist/assets/{dist-zk56dqRQ.js → dist-CryXvMV_.js} +1 -1
  64. package/inspector/dist/assets/{dist-BiWJ3XY1.js → dist-D1hk0iC5.js} +1 -1
  65. package/inspector/dist/assets/{dist-CH9pz3rR.js → dist-DvW9QE1d.js} +1 -1
  66. package/inspector/dist/assets/{dist-Dno2CmWo.js → dist-VD9jMxBv.js} +1 -1
  67. package/inspector/dist/assets/{dist-BKBuUSVQ.js → dist-VlAqWD8T.js} +1 -1
  68. package/inspector/dist/assets/{editor-CMQMyCOv.js → editor-DkWqwl89.js} +3 -3
  69. package/inspector/dist/assets/index-Bra74Nxe.css +2 -0
  70. package/inspector/dist/assets/index-DRJS2pGj.js +229 -0
  71. package/inspector/dist/index.html +2 -2
  72. package/package.json +7 -2
  73. package/inspector/dist/assets/dist-Bj93ipvc.js +0 -1
  74. package/inspector/dist/assets/index-1wfYRgHE.css +0 -2
  75. package/inspector/dist/assets/index-CtygNkZJ.js +0 -229
@@ -0,0 +1,117 @@
1
+ import path from "node:path";
2
+ import { publicUrlFor, resolveProject } from "../../config.js";
3
+ import { disposeAllBackends } from "../chat/sessions.js";
4
+ import { cloudStatus } from "../cloud.js";
5
+ import { getProjectRoot } from "../context.js";
6
+ import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, startWorkspace, stopWorkspace, tildify } from "../instances.js";
7
+ import { searchAgents } from "../search.js";
8
+ import { getSettings, saveSettings } from "../settings.js";
9
+ import { canSelfUpdate, startUpdate, updateStatus } from "../update.js";
10
+ import { getDiskVersion, getPkgName, getPkgVersion, RESTART_EXIT_CODE, supervised } from "../version.js";
11
+ import { disposeAllDevs } from "../vibeables.js";
12
+ import { fail, reply, route } from "./router.js";
13
+ /** The workspace itself: identity and features, settings, the other workspaces, updates. */
14
+ export const workspaceRoutes = [
15
+ // ?probe=1 marks a discovery probe from another instance: answer identity
16
+ // only, skip our own discovery (two instances must not probe each other
17
+ // recursively).
18
+ route({ name: "meta.get", method: "GET", path: "/api/meta", summary: "version, workspace identity, enabled features, the switcher" }, async (c) => {
19
+ const project = await resolveProject(getProjectRoot()).catch(() => null);
20
+ const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
21
+ const manual = project?.config.switcher ?? [];
22
+ const discovered = c.query.get("probe") === "1" ? [] : await discoverWorkspaces();
23
+ // Manual switcher entries keep their configured name/icon; discovered
24
+ // workspaces that duplicate one (same url, running) only contribute
25
+ // the live flag. Stopped workspaces never collide — they carry a root,
26
+ // and a manual entry pointing at the same port is just a link.
27
+ const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
28
+ const manualUrls = new Set(manual.map((e) => norm(e.url)));
29
+ const liveUrls = new Set(discovered.filter((d) => d.live).map((d) => norm(d.url)));
30
+ const switcher = [
31
+ ...manual.map((e) => (liveUrls.has(norm(e.url)) ? { ...e, live: true } : e)),
32
+ ...discovered.filter((d) => !(d.live && manualUrls.has(norm(d.url)))),
33
+ ];
34
+ return {
35
+ version: await getPkgVersion(),
36
+ // A differing disk version means an upgrade landed while this process
37
+ // runs — the UI offers a relaunch when a supervisor can respawn us.
38
+ diskVersion: await getDiskVersion(),
39
+ restartable: supervised(),
40
+ projectName,
41
+ projectIcon: project?.config.icon ?? "",
42
+ projectColor: project?.config.color ?? "",
43
+ projectNamed: !!project?.config.name,
44
+ projectRoot: project?.root ?? getProjectRoot(),
45
+ projectRootLabel: tildify(project?.root ?? getProjectRoot()),
46
+ git: (project?.config.git && project.config.git.enabled !== false) === true,
47
+ repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
48
+ vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
49
+ projects: (project?.config.projects && project.config.projects.enabled !== false) === true,
50
+ publicUrl: (project && publicUrlFor(project)) ?? "",
51
+ cloud: cloudStatus(),
52
+ switcher,
53
+ };
54
+ }),
55
+ route({ name: "settings.get", method: "GET", path: "/api/settings", summary: "kraftwerk.yml, parsed, with resolved paths" }, async () => getSettings()),
56
+ route({ name: "settings.save", method: "PUT", path: "/api/settings", summary: "write the UI-editable subset of kraftwerk.yml", errors: 400 }, async (c) => saveSettings(await c.body())),
57
+ route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => listWorkspacesDetailed()),
58
+ // Launches `kraftwerk ui` for a known workspace as a detached process (the switcher's Start button).
59
+ route({ name: "workspaces.start", method: "POST", path: "/api/workspaces/start", summary: "start a known workspace {root}", errors: 400 }, async (c) => {
60
+ const { root } = await c.body();
61
+ if (!root)
62
+ fail(400, "root required");
63
+ const result = await startWorkspace(root);
64
+ return reply(result.ok ? 200 : 409, result);
65
+ }),
66
+ route({ name: "workspaces.stop", method: "POST", path: "/api/workspaces/stop", summary: "stop a running workspace's server {root | url}", errors: 400 }, async (c) => {
67
+ const target = await c.body();
68
+ if (!target.root && !target.url)
69
+ fail(400, "root or url required");
70
+ const result = await stopWorkspace(target);
71
+ return reply(result.ok ? 200 : 409, result);
72
+ }),
73
+ route({ name: "workspaces.forget", method: "POST", path: "/api/workspaces/forget", summary: "drop a workspace from the registry {root}", errors: 400 }, async (c) => {
74
+ const { root } = await c.body();
75
+ if (!root)
76
+ fail(400, "root required");
77
+ return { ok: await forgetWorkspace(root) };
78
+ }),
79
+ route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents()),
80
+ route({ name: "update.check", method: "GET", path: "/api/update-check", summary: "the latest version on the npm registry" }, async () => {
81
+ const name = await getPkgName();
82
+ const current = await getDiskVersion();
83
+ try {
84
+ const r = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5_000) });
85
+ if (!r.ok)
86
+ throw new Error(String(r.status));
87
+ const latest = (await r.json()).version ?? "";
88
+ return { name, current, latest };
89
+ }
90
+ catch {
91
+ return reply(502, { name, current, latest: "", error: "npm registry unreachable" });
92
+ }
93
+ }),
94
+ route({ name: "update.status", method: "GET", path: "/api/update", summary: "state and log of the self-update, and whether one can run here" }, async () => {
95
+ const name = await getPkgName();
96
+ const can = canSelfUpdate(name);
97
+ return { ...updateStatus(), name, available: can.ok, reason: can.ok ? undefined : can.reason, restartable: supervised() };
98
+ }),
99
+ // Runs `npm i -g <name>@<version>`; 409 when refused or already running.
100
+ route({ name: "update.start", method: "POST", path: "/api/update", summary: "self-update to {version?} (default latest)", errors: 409 }, async (c) => {
101
+ const name = await getPkgName();
102
+ const body = await c.body();
103
+ const version = typeof body.version === "string" && body.version ? body.version : "latest";
104
+ return reply(202, { ...startUpdate(name, version), name });
105
+ }),
106
+ // Exits with the respawn code; the `kraftwerk ui` supervisor relaunches.
107
+ route({ name: "server.restart", method: "POST", path: "/api/restart", summary: "relaunch the server (needs the `kraftwerk ui` supervisor)" }, async () => {
108
+ if (!supervised())
109
+ fail(400, "not supervised — restart `kraftwerk ui` manually");
110
+ setTimeout(() => {
111
+ disposeAllBackends();
112
+ disposeAllDevs();
113
+ process.exit(RESTART_EXIT_CODE);
114
+ }, 150);
115
+ return { ok: true };
116
+ }),
117
+ ];
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import { getOutputDir, getProjectRoot } from "../context.js";
4
+ import { perWorkspace } from "../workspace.js";
5
+ import { touch } from "../live.js";
4
6
  import { knowledgeIndex } from "../knowledge.js";
5
7
  import { getRun, listRuns, safeRunDir } from "../runs.js";
6
8
  import { listSkills, readSkill } from "../skills.js";
@@ -19,7 +21,8 @@ import { JOURNAL_FILE, journalExcerpt, readJournal } from "../journal.js";
19
21
  import { filesSummary } from "../files.js";
20
22
  import { appendEvent, attachmentPath, copyChatFiles, listChatMetas, newChatId, readEvents, readMeta, readMetaAt, safeChatDir, writeMeta, } from "./store.js";
21
23
  const MAIN = "main";
22
- const states = new Map();
24
+ /** Chats with a loaded state, by id — one table per workspace. */
25
+ const chatStates = perWorkspace(() => new Map());
23
26
  const newSeat = (key, harness) => ({
24
27
  key,
25
28
  harness,
@@ -130,21 +133,25 @@ function dropBackend(state) {
130
133
  const IDLE_BACKEND_MS = 15 * 60_000;
131
134
  const reaper = setInterval(() => {
132
135
  const cutoff = Date.now() - IDLE_BACKEND_MS;
133
- for (const state of states.values()) {
134
- const live = [...state.seats.values()].some((s) => s.backend);
135
- if (live && !isBusy(state) && Date.parse(state.meta.updatedAt) < cutoff) {
136
- dropBackend(state);
137
- }
136
+ for (const [ws, states] of chatStates.entries()) {
137
+ ws.run(() => {
138
+ for (const state of chatStates().values()) {
139
+ const live = [...state.seats.values()].some((s) => s.backend);
140
+ if (live && !isBusy(state) && Date.parse(state.meta.updatedAt) < cutoff) {
141
+ dropBackend(state);
142
+ }
143
+ }
144
+ });
138
145
  }
139
146
  }, 60_000);
140
147
  reaper.unref?.();
141
148
  /** Server shutdown: no agent subprocess may outlive the inspector. */
142
149
  export function disposeAllBackends() {
143
- for (const state of states.values())
144
- dropBackend(state);
150
+ for (const [ws, states] of chatStates.entries())
151
+ ws.run(() => states.forEach((state) => dropBackend(state)));
145
152
  }
146
153
  async function loadState(id) {
147
- const existing = states.get(id);
154
+ const existing = chatStates().get(id);
148
155
  if (existing)
149
156
  return existing;
150
157
  const meta = await readMeta(id);
@@ -162,7 +169,7 @@ async function loadState(id) {
162
169
  writeChain: Promise.resolve(),
163
170
  };
164
171
  // Two racing loads: keep whichever registered first.
165
- const registered = states.get(id) ?? (states.set(id, state), state);
172
+ const registered = chatStates().get(id) ?? (chatStates().set(id, state), state);
166
173
  if (registered === state)
167
174
  closeInterruptedWork(state);
168
175
  return registered;
@@ -241,8 +248,13 @@ function emit(state, ev, from) {
241
248
  .catch(() => { });
242
249
  for (const fn of state.subscribers)
243
250
  fn(stored);
251
+ // What a list shows about a chat (busy, waiting for you) changed: live watches re-evaluate.
252
+ if (STATUS_EVENTS.has(ev.type))
253
+ touch();
244
254
  return stored;
245
255
  }
256
+ /** Events that change a chat's state as lists show it — not the streamed text in between. */
257
+ const STATUS_EVENTS = new Set(["user_message", "turn_start", "turn_end", "error", "failure", "permission_request", "permission_resolved", "elicitation_request", "elicitation_resolved", "files_changed"]);
246
258
  /* ---------- skills ---------- */
247
259
  /**
248
260
  * Skills visible to a chat: every discovered skill (workspace skills root
@@ -858,7 +870,7 @@ export async function createChat(opts) {
858
870
  updatedAt: now,
859
871
  };
860
872
  await writeMeta(meta);
861
- states.set(meta.id, {
873
+ chatStates().set(meta.id, {
862
874
  meta,
863
875
  events: [],
864
876
  subscribers: new Set(),
@@ -873,12 +885,12 @@ export async function createChat(opts) {
873
885
  }
874
886
  /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
875
887
  export function chatAwaitingApproval(id) {
876
- return (states.get(id)?.pendingInfo.size ?? 0) > 0;
888
+ return (chatStates().get(id)?.pendingInfo.size ?? 0) > 0;
877
889
  }
878
890
  /** Every request waiting for a human, across the chats in memory. */
879
891
  export function waitingRequests() {
880
892
  const out = [];
881
- for (const state of states.values()) {
893
+ for (const state of chatStates().values()) {
882
894
  for (const [requestId, info] of state.pendingInfo) {
883
895
  out.push({ requestId, ...info, meta: state.meta, seat: state.pendingSeat.get(requestId) ?? MAIN });
884
896
  }
@@ -894,7 +906,7 @@ export function waitingRequests() {
894
906
  export function agentActivity() {
895
907
  const out = new Map();
896
908
  const of = (slug) => out.get(slug) ?? out.set(slug, { working: [], waiting: [] }).get(slug);
897
- for (const state of states.values()) {
909
+ for (const state of chatStates().values()) {
898
910
  const { meta } = state;
899
911
  for (const seat of state.seats.values()) {
900
912
  const slug = meta.scope.kind === "agent" ? meta.scope.slug : meta.scope.kind === "channel" ? seat.key : undefined;
@@ -912,8 +924,8 @@ export function agentActivity() {
912
924
  export async function listChats() {
913
925
  const metas = await listChatMetas();
914
926
  return metas.map((m) => ({
915
- ...(states.get(m.id)?.meta ?? m),
916
- busy: states.has(m.id) ? isBusy(states.get(m.id)) : false,
927
+ ...(chatStates().get(m.id)?.meta ?? m),
928
+ busy: chatStates().has(m.id) ? isBusy(chatStates().get(m.id)) : false,
917
929
  awaitingApproval: chatAwaitingApproval(m.id),
918
930
  }));
919
931
  }
@@ -1130,7 +1142,7 @@ export async function ensureChannelChat(channel) {
1130
1142
  const metas = await listChatMetas();
1131
1143
  const existing = metas.find((m) => m.scope.kind === "channel" && m.scope.slug === channel.slug);
1132
1144
  if (existing)
1133
- return states.get(existing.id)?.meta ?? existing;
1145
+ return chatStates().get(existing.id)?.meta ?? existing;
1134
1146
  const meta = await createChat({ agent: "claude", scope: { kind: "channel", slug: channel.slug }, title: channel.name });
1135
1147
  if (channel.project) {
1136
1148
  meta.project = channel.project;
@@ -1191,7 +1203,7 @@ export async function convertChatToChannel(chatId, channel) {
1191
1203
  }
1192
1204
  /** Members left the channel: their processes go, the transcript stays. */
1193
1205
  export async function dropChannelSeats(chatId, keep) {
1194
- const state = states.get(chatId);
1206
+ const state = chatStates().get(chatId);
1195
1207
  if (!state)
1196
1208
  return;
1197
1209
  for (const [key, seat] of state.seats) {
@@ -1423,7 +1435,7 @@ export async function deleteChat(id) {
1423
1435
  for (const resolve of state.pendingElicitations.values())
1424
1436
  resolve({ action: "cancel" });
1425
1437
  dropBackend(state);
1426
- states.delete(id);
1438
+ chatStates().delete(id);
1427
1439
  await moveToTrash("chats", id, safeChatDir(id));
1428
1440
  return {};
1429
1441
  }
@@ -1,10 +1,8 @@
1
- export declare function setOutputDir(dir: string): void;
2
- export declare function setProjectRoot(dir: string): void;
3
- /** Absolute output directory (runs/ + chats/ live inside). */
4
- export declare function getOutputDir(): string;
5
1
  /**
6
- * The consumer project root. Set explicitly at startup; falls back to the
7
- * parent of the output dir (wrong for nested output dirs like
8
- * kraftwerk-data/output — always pass projectRoot when known).
2
+ * Shorthands for the current workspace's paths (see workspace.ts for how
3
+ * the current workspace is chosen).
9
4
  */
5
+ /** Absolute output directory (runs/ + chats/ live inside). */
6
+ export declare function getOutputDir(): string;
7
+ /** The workspace root: where kraftwerk.yml lives. */
10
8
  export declare function getProjectRoot(): string;
@@ -1,27 +1,13 @@
1
- import path from "node:path";
1
+ import { currentWorkspace } from "./workspace.js";
2
2
  /**
3
- * Server-side context for the inspector: which consumer project it looks at.
4
- * Set once by startInspector() before any request is served.
3
+ * Shorthands for the current workspace's paths (see workspace.ts for how
4
+ * the current workspace is chosen).
5
5
  */
6
- let outputDir = "";
7
- let projectRoot = "";
8
- export function setOutputDir(dir) {
9
- outputDir = path.resolve(dir);
10
- }
11
- export function setProjectRoot(dir) {
12
- projectRoot = path.resolve(dir);
13
- }
14
6
  /** Absolute output directory (runs/ + chats/ live inside). */
15
7
  export function getOutputDir() {
16
- if (!outputDir)
17
- throw new Error("inspector context not initialized");
18
- return outputDir;
8
+ return currentWorkspace().outputDir;
19
9
  }
20
- /**
21
- * The consumer project root. Set explicitly at startup; falls back to the
22
- * parent of the output dir (wrong for nested output dirs like
23
- * kraftwerk-data/output — always pass projectRoot when known).
24
- */
10
+ /** The workspace root: where kraftwerk.yml lives. */
25
11
  export function getProjectRoot() {
26
- return projectRoot || path.dirname(getOutputDir());
12
+ return currentWorkspace().root;
27
13
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { projectsRootFor, reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, getProjectRoot } from "./context.js";
7
+ import { perWorkspace } from "./workspace.js";
7
8
  /**
8
9
  * Workspace git sync. Several people run kraftwerk against one git repo, so
9
10
  * knowledge, agents, skills, channels and workflows travel between them like
@@ -325,13 +326,26 @@ export function parseStatus(out) {
325
326
  }
326
327
  return files;
327
328
  }
328
- let statusCache = null;
329
- /** Bumped by touched(); a status computed under an older value is stale. */
330
- let generation = 0;
329
+ /**
330
+ * The workspace's sync state: the status cache, the queue of repo-mutating
331
+ * commands, the last fetch/pull/error, and the background timer.
332
+ */
333
+ const gitState = perWorkspace(() => ({
334
+ statusCache: null,
335
+ /** Bumped by touched(); a status computed under an older value is stale. */
336
+ generation: 0,
337
+ chain: Promise.resolve(),
338
+ lastFetch: undefined,
339
+ lastPull: undefined,
340
+ lastError: undefined,
341
+ timer: null,
342
+ ticking: false,
343
+ lastRun: 0,
344
+ }));
331
345
  /** Invalidate the status cache after anything that changes the repo. */
332
346
  const touched = () => {
333
- statusCache = null;
334
- generation++;
347
+ gitState().statusCache = null;
348
+ gitState().generation++;
335
349
  };
336
350
  /**
337
351
  * One repo-mutating git command at a time. The background sync and the
@@ -341,24 +355,21 @@ const touched = () => {
341
355
  * fetch, pull and push queue behind each other. Status stays outside the
342
356
  * queue (GIT_OPTIONAL_LOCKS=0 keeps it lock-free).
343
357
  */
344
- let chain = Promise.resolve();
345
358
  function serial(fn) {
346
- const run = chain.then(fn, fn);
347
- chain = run.catch(() => { });
359
+ const run = gitState().chain.then(fn, fn);
360
+ gitState().chain = run.catch(() => { });
348
361
  return run;
349
362
  }
350
- let lastFetch;
351
- let lastPull;
352
- let lastError;
353
363
  /**
354
364
  * Everything the git screen renders. Each call shells out to git a handful
355
365
  * of times, and the nav badge polls it from every open tab, so results are
356
366
  * cached for a beat.
357
367
  */
358
368
  export async function gitStatus(fresh = false) {
359
- if (!fresh && statusCache && Date.now() - statusCache.at < CACHE_MS)
360
- return statusCache.value;
361
- const gen = generation;
369
+ const cached = gitState().statusCache;
370
+ if (!fresh && cached && Date.now() - cached.at < CACHE_MS)
371
+ return cached.value;
372
+ const gen = gitState().generation;
362
373
  const opened = await openRepo();
363
374
  if (opened.off)
364
375
  return { enabled: false };
@@ -393,17 +404,17 @@ export async function gitStatus(fresh = false) {
393
404
  diverged: !!ahead && !!behind,
394
405
  autosync: repo.cfg.autosync,
395
406
  interval: repo.cfg.interval,
396
- lastFetch,
397
- lastPull,
398
- lastError,
407
+ lastFetch: gitState().lastFetch,
408
+ lastPull: gitState().lastPull,
409
+ lastError: gitState().lastError,
399
410
  };
400
411
  // A failed status (a stale index.lock, a timeout on a huge untracked tree)
401
412
  // must not read as "workspace clean": with no file list the commit endpoint
402
413
  // refuses everything, and the message should say why.
403
414
  if (!status.ok) {
404
415
  const value = { ...base, error: `git status failed: ${status.stderr || "unknown error"}` };
405
- if (gen === generation)
406
- statusCache = { at: Date.now(), value };
416
+ if (gen === gitState().generation)
417
+ gitState().statusCache = { at: Date.now(), value };
407
418
  return value;
408
419
  }
409
420
  const scope = scopeFor(repo);
@@ -428,8 +439,8 @@ export async function gitStatus(fresh = false) {
428
439
  // A commit that finished while this status was running already emptied
429
440
  // the cache; caching the pre-commit list on top of that would show the
430
441
  // committed file as dirty for another CACHE_MS.
431
- if (gen === generation)
432
- statusCache = { at: Date.now(), value };
442
+ if (gen === gitState().generation)
443
+ gitState().statusCache = { at: Date.now(), value };
433
444
  return value;
434
445
  }
435
446
  /**
@@ -556,11 +567,11 @@ async function fetchNow() {
556
567
  // put "fetched 5s ago" next to ahead/behind counts that stopped moving
557
568
  // hours ago, which is the one thing this line exists to rule out.
558
569
  if (!r.ok) {
559
- lastError = r.stderr || "fetch failed";
560
- return { ok: false, error: lastError };
570
+ gitState().lastError = r.stderr || "fetch failed";
571
+ return { ok: false, error: gitState().lastError };
561
572
  }
562
- lastFetch = new Date().toISOString();
563
- lastError = undefined;
573
+ gitState().lastFetch = new Date().toISOString();
574
+ gitState().lastError = undefined;
564
575
  return { ok: true };
565
576
  }
566
577
  /** Fast-forward only. A diverged branch is the user's problem, not ours. */
@@ -584,13 +595,13 @@ async function pullNow() {
584
595
  // git's own non-fast-forward output is a wall of hints. Say the one
585
596
  // thing that matters and leave the resolution to a terminal.
586
597
  const notFf = /non-fast-forward|diverg|cannot be fast-forwarded/i.test(r.stderr);
587
- lastError = notFf
598
+ gitState().lastError = notFf
588
599
  ? "The branch has diverged from its upstream. Merge or rebase it in a terminal."
589
600
  : r.stderr || "pull failed";
590
- return { ok: false, error: lastError };
601
+ return { ok: false, error: gitState().lastError };
591
602
  }
592
- lastPull = new Date().toISOString();
593
- lastError = undefined;
603
+ gitState().lastPull = new Date().toISOString();
604
+ gitState().lastError = undefined;
594
605
  return { ok: true };
595
606
  }
596
607
  export function gitPush() {
@@ -610,10 +621,10 @@ async function pushNow() {
610
621
  : await gitNet(netArgs(["push", "--set-upstream"], cfg.remote, `HEAD:${head.branch}`), repoRoot);
611
622
  touched();
612
623
  if (!r.ok) {
613
- lastError = r.stderr || "push failed";
614
- return { ok: false, error: lastError };
624
+ gitState().lastError = r.stderr || "push failed";
625
+ return { ok: false, error: gitState().lastError };
615
626
  }
616
- lastError = undefined;
627
+ gitState().lastError = undefined;
617
628
  return { ok: true };
618
629
  }
619
630
  /* ---------- background sync ---------- */
@@ -624,9 +635,6 @@ async function pushNow() {
624
635
  * next restart.
625
636
  */
626
637
  const RECHECK_MS = 60_000;
627
- let timer = null;
628
- let ticking = false;
629
- let lastRun = 0;
630
638
  /**
631
639
  * Fetch on an interval, and fast-forward when `autosync: pull` is set. Never
632
640
  * commits. A pull is skipped while the tree is dirty, so nobody's unsaved
@@ -635,34 +643,35 @@ let lastRun = 0;
635
643
  * out) before the next one starts, or a short interval piles up processes.
636
644
  */
637
645
  export function startGitSync() {
638
- if (timer || ticking)
646
+ if (gitState().timer || gitState().ticking)
639
647
  return;
640
648
  schedule(0);
641
649
  }
642
650
  function schedule(ms) {
643
- timer = setTimeout(() => void tick(), ms);
651
+ const timer = setTimeout(() => void tick(), ms);
644
652
  timer.unref?.();
653
+ gitState().timer = timer;
645
654
  }
646
655
  async function tick() {
647
- timer = null;
648
- ticking = true;
656
+ gitState().timer = null;
657
+ gitState().ticking = true;
649
658
  let next = RECHECK_MS;
650
659
  try {
651
660
  const { repo } = await openRepo();
652
661
  if (repo && repo.cfg.interval > 0) {
653
662
  const every = repo.cfg.interval * 1000;
654
- if (Date.now() - lastRun >= every - 500) {
655
- lastRun = Date.now();
663
+ if (Date.now() - gitState().lastRun >= every - 500) {
664
+ gitState().lastRun = Date.now();
656
665
  await sync(repo);
657
666
  }
658
- next = Math.min(RECHECK_MS, Math.max(1_000, lastRun + every - Date.now()));
667
+ next = Math.min(RECHECK_MS, Math.max(1_000, gitState().lastRun + every - Date.now()));
659
668
  }
660
669
  }
661
670
  catch {
662
671
  // A failed tick is retried at the next one; the status carries lastError.
663
672
  }
664
673
  finally {
665
- ticking = false;
674
+ gitState().ticking = false;
666
675
  schedule(next);
667
676
  }
668
677
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Live data for watching clients: a watch is a route result some client
3
+ * keeps on screen (the rail's agents, the bell's notifications). The server
4
+ * evaluates each distinct watch once — however many clients share it — and
5
+ * pushes it only when it changed:
6
+ *
7
+ * - right after any change made through the API (`touch()` from the router),
8
+ * - when a chat's state changes (turn start/end, a question, an answer),
9
+ * - and on the watch's interval, for changes nobody announces (an agent
10
+ * writing files, a run progressing on disk).
11
+ *
12
+ * Evaluation runs in the watch's workspace. This module knows nothing about
13
+ * routes: the socket hands it an `evaluate` closure.
14
+ */
15
+ interface Watcher {
16
+ interval: number;
17
+ push(data: unknown): void;
18
+ }
19
+ interface Watch {
20
+ evaluate(): Promise<{
21
+ ok: boolean;
22
+ data: unknown;
23
+ }>;
24
+ watchers: Set<Watcher>;
25
+ /** The last pushed JSON, to push changes only. */
26
+ last?: string;
27
+ timer?: ReturnType<typeof setTimeout>;
28
+ /** The evaluation in flight, and the one waiting behind it. */
29
+ running?: Promise<void>;
30
+ queued?: {
31
+ promise: Promise<void>;
32
+ forced: Set<Watcher>;
33
+ };
34
+ }
35
+ /**
36
+ * Keep `push` fed with the result of `evaluate` (shared by `key`): now, on
37
+ * every change, and at least every `interval` ms. Returns the unwatch.
38
+ */
39
+ export declare function watch(key: string, evaluate: Watch["evaluate"], interval: number, push: (data: unknown) => void): () => void;
40
+ /** Something changed: re-evaluate every watch of this workspace soon (debounced). */
41
+ export declare function touch(): void;
42
+ export {};
@@ -0,0 +1,91 @@
1
+ import { perWorkspace } from "./workspace.js";
2
+ const MIN_INTERVAL = 1000;
3
+ const TOUCH_DEBOUNCE_MS = 60;
4
+ const state = perWorkspace(() => ({ watches: new Map(), touchTimer: undefined }));
5
+ /**
6
+ * Evaluate after whatever evaluation is in flight (it may predate the
7
+ * change that asked for this one). Requests that arrive while one waits
8
+ * share it. Changed data goes to every watcher; `force` watchers (just
9
+ * joined) get it even unchanged.
10
+ */
11
+ function run(w, force) {
12
+ if (w.queued) {
13
+ if (force)
14
+ w.queued.forced.add(force);
15
+ return w.queued.promise;
16
+ }
17
+ const forced = new Set(force ? [force] : []);
18
+ const promise = (w.running ?? Promise.resolve()).then(async () => {
19
+ w.queued = undefined;
20
+ try {
21
+ const r = await w.evaluate();
22
+ // A failed evaluation keeps the last good value on every client.
23
+ if (!r.ok)
24
+ return;
25
+ const json = JSON.stringify(r.data) ?? "null";
26
+ const changed = json !== w.last;
27
+ w.last = json;
28
+ for (const x of w.watchers)
29
+ if (changed || forced.has(x))
30
+ x.push(r.data);
31
+ }
32
+ catch { }
33
+ });
34
+ w.queued = { promise, forced };
35
+ w.running = promise;
36
+ return promise;
37
+ }
38
+ function schedule(w) {
39
+ clearTimeout(w.timer);
40
+ if (!w.watchers.size)
41
+ return;
42
+ const every = Math.max(MIN_INTERVAL, Math.min(...[...w.watchers].map((x) => x.interval)));
43
+ w.timer = setTimeout(() => void run(w, undefined).finally(() => schedule(w)), every);
44
+ w.timer.unref?.();
45
+ }
46
+ /**
47
+ * Keep `push` fed with the result of `evaluate` (shared by `key`): now, on
48
+ * every change, and at least every `interval` ms. Returns the unwatch.
49
+ */
50
+ export function watch(key, evaluate, interval, push) {
51
+ const { watches } = state();
52
+ let w = watches.get(key);
53
+ if (!w) {
54
+ w = { evaluate, watchers: new Set() };
55
+ watches.set(key, w);
56
+ }
57
+ const watcher = { interval, push };
58
+ w.watchers.add(watcher);
59
+ // A new watcher gets the current value at once, changed or not.
60
+ void run(w, watcher).finally(() => schedule(w));
61
+ const current = w;
62
+ return () => {
63
+ current.watchers.delete(watcher);
64
+ if (!current.watchers.size) {
65
+ clearTimeout(current.timer);
66
+ if (watches.get(key) === current)
67
+ watches.delete(key);
68
+ }
69
+ else
70
+ schedule(current);
71
+ };
72
+ }
73
+ /** Something changed: re-evaluate every watch of this workspace soon (debounced). */
74
+ export function touch() {
75
+ // Outside any workspace (a unit test, a CLI command) nobody can be watching.
76
+ let s;
77
+ try {
78
+ s = state();
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (!s.watches.size || s.touchTimer)
84
+ return;
85
+ s.touchTimer = setTimeout(() => {
86
+ s.touchTimer = undefined;
87
+ for (const w of s.watches.values())
88
+ void run(w, undefined).finally(() => schedule(w));
89
+ }, TOUCH_DEBOUNCE_MS);
90
+ s.touchTimer.unref?.();
91
+ }