@netnodeag/kraftwerk 0.75.1 → 0.76.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 (45) hide show
  1. package/dist/cli/daemon-control.js +4 -2
  2. package/dist/cli/doctor.d.ts +3 -1
  3. package/dist/cli/doctor.js +36 -2
  4. package/dist/cli/init.js +5 -2
  5. package/dist/cli/kraftwerk.js +9 -5
  6. package/dist/cli/routines.d.ts +2 -1
  7. package/dist/cli/routines.js +40 -4
  8. package/dist/cli/whoami.d.ts +9 -0
  9. package/dist/cli/whoami.js +48 -0
  10. package/dist/client/routes.d.ts +3 -0
  11. package/dist/client/routes.js +3 -0
  12. package/dist/config.d.ts +20 -0
  13. package/dist/config.js +38 -3
  14. package/dist/core/attention.d.ts +8 -1
  15. package/dist/core/attention.js +38 -2
  16. package/dist/core/identity.d.ts +46 -0
  17. package/dist/core/identity.js +106 -0
  18. package/dist/core/routines.d.ts +81 -0
  19. package/dist/core/routines.js +174 -33
  20. package/dist/server/api/agents.d.ts +5 -1
  21. package/dist/server/api/agents.js +19 -2
  22. package/dist/server/api/index.d.ts +14 -3
  23. package/dist/server/api/runs.d.ts +3 -1
  24. package/dist/server/api/runs.js +9 -1
  25. package/dist/server/api/workspace.d.ts +6 -1
  26. package/dist/server/api/workspace.js +5 -0
  27. package/dist/server/push.js +1 -1
  28. package/package.json +1 -1
  29. package/schema/kraftwerk.schema.json +5 -0
  30. package/web/dist/assets/{dist-YNCNGGgK.js → dist-BQpWYkxm.js} +1 -1
  31. package/web/dist/assets/{dist-DaGnyH_l.js → dist-BTVP-6On.js} +1 -1
  32. package/web/dist/assets/dist-BURf1l-A.js +1 -0
  33. package/web/dist/assets/{dist-CLZjnhZZ.js → dist-BYkcY-Vl.js} +1 -1
  34. package/web/dist/assets/{dist-CzM6tn0f.js → dist-BwIca_Uj.js} +1 -1
  35. package/web/dist/assets/{dist-Y9aHncjU.js → dist-DFXmaDc5.js} +1 -1
  36. package/web/dist/assets/{dist-CezfArgg.js → dist-T1sNLC62.js} +1 -1
  37. package/web/dist/assets/{dist-CYicby0c.js → dist-fgB7OEJy.js} +1 -1
  38. package/web/dist/assets/{dist-QqFmuPda.js → dist-flnLXEzU.js} +1 -1
  39. package/web/dist/assets/{dist-BI4umsk6.js → dist-l7foWlFx.js} +1 -1
  40. package/web/dist/assets/{dist-CODxhtAK.js → dist-vuciRCSz.js} +1 -1
  41. package/web/dist/assets/{editor-DEb8z5-Q.js → editor-C6Ok8NPo.js} +3 -3
  42. package/web/dist/assets/index-z4tpCCzk.js +231 -0
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/assets/dist-DtktO5hc.js +0 -1
  45. package/web/dist/assets/index-kmyK4tFm.js +0 -231
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Who and where this kraftwerk is: ~/.kraftwerk/identity.json, one per
3
+ * machine (every workspace here shares it).
4
+ *
5
+ * { "handle": "lukas", "machine": "mbp", "previous": ["lukas@old-name"] }
6
+ *
7
+ * The identity string is `handle@machine`. A workspace is git-shared, its
8
+ * run state is not, so a routine names the one identity that fires it
9
+ * (`runs_on:` in routines.yml) — without that every clone would fire it.
10
+ *
11
+ * Defaults come from the OS (user name, host name without its domain) and
12
+ * are written the first time they are read: macOS changes the host name
13
+ * with the network, and an identity that drifts with it would silently stop
14
+ * every routine assigned to this machine. Renaming appends the retired pair
15
+ * to `previous`, so routines that still name it can be found and healed.
16
+ */
17
+ export interface Identity {
18
+ handle: string;
19
+ machine: string;
20
+ /** Retired `handle@machine` pairs, oldest first. */
21
+ previous: string[];
22
+ }
23
+ /** One half of an identity: lowercase, no "@". */
24
+ export declare const IDENTITY_PART_RE: RegExp;
25
+ /** The OS user name. */
26
+ export declare function defaultHandle(): string;
27
+ /** The host name, lowercased, without `.local` or a domain; anything outside [a-z0-9-] becomes "-". */
28
+ export declare function defaultMachine(hostname?: string): string;
29
+ /** Why a handle or machine name is refused, or undefined when it is fine. */
30
+ export declare function identityPartError(label: "handle" | "machine", value: string): string | undefined;
31
+ /** `handle@machine` with both halves well-formed: what `runs_on:` must look like. */
32
+ export declare function isIdentityString(value: string): boolean;
33
+ export declare const identityString: (id: Pick<Identity, "handle" | "machine">) => string;
34
+ /**
35
+ * This machine's identity. A missing file gets the OS defaults, written so
36
+ * they stay put; a damaged field falls back to its default without being
37
+ * written over (a person may be halfway through editing the file).
38
+ */
39
+ export declare function readIdentity(): Promise<Identity>;
40
+ /** This machine's `handle@machine`. */
41
+ export declare function localIdentity(): Promise<string>;
42
+ /** Change the handle and/or machine name; the pair it replaces goes to `previous`. */
43
+ export declare function setIdentity(change: {
44
+ handle?: string;
45
+ machine?: string;
46
+ }): Promise<Identity>;
@@ -0,0 +1,106 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** One half of an identity: lowercase, no "@". */
6
+ export const IDENTITY_PART_RE = /^[a-z0-9._-]+$/;
7
+ /** How many retired pairs are kept. */
8
+ const MAX_PREVIOUS = 10;
9
+ // Resolved on every use, not at import: a test swaps HOME after some module already imported this one.
10
+ const identityFile = () => path.join(os.homedir(), ".kraftwerk", "identity.json");
11
+ /** A free-form name as an identity half: lowercase, anything outside [a-z0-9._-] becomes "-". */
12
+ function sanitize(raw, fallback) {
13
+ const s = raw
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9._-]+/g, "-")
16
+ .replace(/-{2,}/g, "-")
17
+ .replace(/^[-.]+|[-.]+$/g, "");
18
+ return s || fallback;
19
+ }
20
+ /** The OS user name. */
21
+ export function defaultHandle() {
22
+ let name = "";
23
+ try {
24
+ name = os.userInfo().username;
25
+ }
26
+ catch {
27
+ name = process.env.USER ?? process.env.USERNAME ?? "";
28
+ }
29
+ return sanitize(name, "user");
30
+ }
31
+ /** The host name, lowercased, without `.local` or a domain; anything outside [a-z0-9-] becomes "-". */
32
+ export function defaultMachine(hostname = os.hostname()) {
33
+ const host = hostname.toLowerCase().split(".")[0] ?? "";
34
+ const s = host.replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
35
+ return s || "machine";
36
+ }
37
+ /** Why a handle or machine name is refused, or undefined when it is fine. */
38
+ export function identityPartError(label, value) {
39
+ if (!value)
40
+ return `${label} must not be empty`;
41
+ if (value.includes("@"))
42
+ return `${label} must not contain "@" (the identity is handle@machine)`;
43
+ if (!IDENTITY_PART_RE.test(value))
44
+ return `${label} must be lowercase letters, digits, ".", "_" or "-"`;
45
+ return undefined;
46
+ }
47
+ /** `handle@machine` with both halves well-formed: what `runs_on:` must look like. */
48
+ export function isIdentityString(value) {
49
+ const parts = value.split("@");
50
+ return parts.length === 2 && IDENTITY_PART_RE.test(parts[0]) && IDENTITY_PART_RE.test(parts[1]);
51
+ }
52
+ export const identityString = (id) => `${id.handle}@${id.machine}`;
53
+ async function write(id) {
54
+ const file = identityFile();
55
+ await fs.mkdir(path.dirname(file), { recursive: true });
56
+ // A temp name per write: two writers in flight must not rename each other's file.
57
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
58
+ await fs.writeFile(tmp, JSON.stringify(id, null, 2) + "\n");
59
+ await fs.rename(tmp, file);
60
+ }
61
+ /**
62
+ * This machine's identity. A missing file gets the OS defaults, written so
63
+ * they stay put; a damaged field falls back to its default without being
64
+ * written over (a person may be halfway through editing the file).
65
+ */
66
+ export async function readIdentity() {
67
+ let raw = null;
68
+ try {
69
+ raw = JSON.parse(await fs.readFile(identityFile(), "utf8"));
70
+ }
71
+ catch (err) {
72
+ if (err.code !== "ENOENT")
73
+ raw = {};
74
+ }
75
+ const handle = typeof raw?.handle === "string" && !identityPartError("handle", raw.handle) ? raw.handle : defaultHandle();
76
+ const machine = typeof raw?.machine === "string" && !identityPartError("machine", raw.machine) ? raw.machine : defaultMachine();
77
+ const previous = Array.isArray(raw?.previous) ? raw.previous.filter((p) => typeof p === "string" && isIdentityString(p)) : [];
78
+ const id = { handle, machine, previous };
79
+ if (raw === null)
80
+ await write(id).catch(() => { });
81
+ return id;
82
+ }
83
+ /** This machine's `handle@machine`. */
84
+ export async function localIdentity() {
85
+ return identityString(await readIdentity());
86
+ }
87
+ /** Change the handle and/or machine name; the pair it replaces goes to `previous`. */
88
+ export async function setIdentity(change) {
89
+ for (const label of ["handle", "machine"]) {
90
+ const v = change[label];
91
+ if (v === undefined)
92
+ continue;
93
+ const err = identityPartError(label, v);
94
+ if (err)
95
+ throw new Error(err);
96
+ }
97
+ const cur = await readIdentity();
98
+ const next = { handle: change.handle ?? cur.handle, machine: change.machine ?? cur.machine, previous: cur.previous };
99
+ const before = identityString(cur);
100
+ const after = identityString(next);
101
+ if (before !== after) {
102
+ next.previous = [...cur.previous.filter((p) => p !== before && p !== after), before].slice(-MAX_PREVIOUS);
103
+ }
104
+ await write(next);
105
+ return next;
106
+ }
@@ -10,6 +10,15 @@
10
10
  * session (chat) for the agent and posts the prompt — the run shows up in
11
11
  * the agent's sessions sidebar like any conversation. Schedules use
12
12
  * standard 5-field cron in the server's local time.
13
+ *
14
+ * The definitions travel with the workspace's git, the state does not, so a
15
+ * workspace cloned on three machines would fire every routine three times.
16
+ * Each routine therefore names its runner, `runs_on: <handle>@<machine>`
17
+ * (see identity.ts), and the scheduler is strict: a routine fires only where
18
+ * runs_on is exactly this machine's identity. No runs_on, no runner — an
19
+ * unassigned routine fires nowhere until someone adopts it ("run these
20
+ * here"), which writes runs_on and commits routines.yml so the other clones
21
+ * learn who runs it. `private:` is stored and round-tripped; nothing reads it yet.
13
22
  */
14
23
  export interface Routine {
15
24
  id: string;
@@ -18,8 +27,21 @@ export interface Routine {
18
27
  schedule: string;
19
28
  prompt: string;
20
29
  enabled: boolean;
30
+ /** The one identity (`handle@machine`) that fires it. Absent: unassigned, fires nowhere. */
31
+ runsOn?: string;
32
+ /** Stored as given; no behaviour yet. */
33
+ private?: boolean;
21
34
  }
35
+ /**
36
+ * Who fires a routine, seen from this machine: here, on another identity,
37
+ * or nobody (no runs_on).
38
+ */
39
+ export type RoutineRunner = "here" | "elsewhere" | "unassigned";
22
40
  export interface RoutineStatus extends Routine {
41
+ runner: RoutineRunner;
42
+ /** runs_on names an identity this machine had before (identity.json `previous`): a rename left it behind. */
43
+ retired?: boolean;
44
+ /** Only for an enabled routine that fires here. */
23
45
  nextRunAt?: string;
24
46
  lastRunAt?: string;
25
47
  lastChatId?: string;
@@ -46,6 +68,9 @@ export interface SaveRoutineInput {
46
68
  schedule: string;
47
69
  prompt: string;
48
70
  enabled?: boolean;
71
+ /** Absent: kept as it is (a new routine gets this machine's identity). null or "": unassigned. */
72
+ runsOn?: string | null;
73
+ private?: boolean;
49
74
  }
50
75
  export declare function saveRoutine(slug: string, input: SaveRoutineInput): Promise<Routine>;
51
76
  export declare function deleteRoutine(slug: string, id: string): Promise<void>;
@@ -70,10 +95,66 @@ export declare function allRoutines(workflows: {
70
95
  };
71
96
  workflows: string[];
72
97
  })[]>;
98
+ /** Whether a routine fires on the machine whose identity is `me`: enabled and runs_on exactly that. */
99
+ export declare const firesHere: (r: Routine, me: string) => boolean;
73
100
  export declare function routineStatuses(slug: string): Promise<RoutineStatus[]>;
74
101
  export declare function runRoutineNow(slug: string, id: string): Promise<{
75
102
  chatId: string;
76
103
  }>;
104
+ /**
105
+ * The routines due at `now` that fire on this machine: enabled, runs_on
106
+ * exactly this identity, cron matching. Everything else — unassigned,
107
+ * another machine's, a retired identity's — is never due here.
108
+ */
109
+ export declare function dueRoutines(now: Date, me?: string): Promise<{
110
+ slug: string;
111
+ routine: Routine;
112
+ }[]>;
113
+ export interface RunnerRef {
114
+ agent: string;
115
+ id: string;
116
+ name: string;
117
+ /** What runs_on said before (absent: unassigned). */
118
+ from?: string;
119
+ }
120
+ export interface RunnerFindings {
121
+ /** Enabled routines without runs_on: they fire nowhere. */
122
+ unassigned: RunnerRef[];
123
+ /** Routines whose runs_on is one of this machine's retired identities. */
124
+ retired: RunnerRef[];
125
+ /** Newest change to a routines.yml involved, ISO (the attention item's "since"). */
126
+ since?: string;
127
+ }
128
+ /** Every routine in the workspace that has no runner, or names a retired identity of this machine. */
129
+ export declare function runnerFindings(): Promise<RunnerFindings>;
130
+ export interface AdoptResult {
131
+ /** This machine's identity, now the runner of every routine listed. */
132
+ runsOn: string;
133
+ adopted: RunnerRef[];
134
+ /** routines.yml files written, relative to the workspace root. */
135
+ files: string[];
136
+ /** The git commit of those files; null when nothing changed. Not ok: written, but not committed (why in `error`). */
137
+ commit: {
138
+ ok: boolean;
139
+ error?: string;
140
+ committed?: number;
141
+ } | null;
142
+ }
143
+ /**
144
+ * "Run these here": make this machine the runner of the routines `pick`
145
+ * selects, write routines.yml and commit just the files it touched — the
146
+ * commit is what tells the other clones who runs them. A workspace without
147
+ * git sync, or a commit that fails, keeps the written files and says why.
148
+ */
149
+ export declare function adoptRoutines(pick: (agent: string, r: Routine, previous: string[]) => boolean, message?: string): Promise<AdoptResult>;
150
+ /** Adopt one routine (whatever its runs_on said). */
151
+ export declare function adoptRoutine(slug: string, id: string): Promise<AdoptResult & {
152
+ routine: RoutineStatus;
153
+ }>;
154
+ /** Adopt every enabled routine without runs_on — and, with `retired`, every one naming a retired identity of this machine. */
155
+ export declare function adoptUnassigned(opts?: {
156
+ retired?: boolean;
157
+ }): Promise<AdoptResult>;
77
158
  /** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
78
159
  export declare function startRoutineScheduler(): void;
79
160
  /** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */
@@ -1,11 +1,13 @@
1
- import { promises as fs } from "node:fs";
1
+ import { promises as fs, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse, stringify } from "yaml";
4
- import { getOutputDir } from "./context.js";
4
+ import { getOutputDir, workspaceRoot } from "./context.js";
5
5
  import { perWorkspace } from "./workspace.js";
6
6
  import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
7
7
  import { chatAwaitingApproval, createChat, postMessage } from "./chat/sessions.js";
8
8
  import { pushNotification } from "./notifications.js";
9
+ import { isIdentityString, localIdentity, readIdentity, identityString } from "./identity.js";
10
+ import { gitCommit, repoRootFor } from "./git.js";
9
11
  /* ---------- cron ---------- */
10
12
  const ALIASES = {
11
13
  "@hourly": "0 * * * *",
@@ -100,8 +102,26 @@ function normalizeRoutine(raw) {
100
102
  schedule: String(raw.schedule ?? ""),
101
103
  prompt: String(raw.prompt ?? ""),
102
104
  enabled: raw.enabled !== false,
105
+ ...(typeof raw.runs_on === "string" && raw.runs_on.trim() ? { runsOn: raw.runs_on.trim() } : {}),
106
+ ...(typeof raw.private === "boolean" ? { private: raw.private } : {}),
103
107
  };
104
108
  }
109
+ /** The file's own spelling: snake_case `runs_on`, optional keys only when set. */
110
+ function serializeRoutine(r) {
111
+ return {
112
+ id: r.id,
113
+ name: r.name,
114
+ schedule: r.schedule,
115
+ prompt: r.prompt,
116
+ enabled: r.enabled,
117
+ ...(r.runsOn ? { runs_on: r.runsOn } : {}),
118
+ ...(r.private !== undefined ? { private: r.private } : {}),
119
+ };
120
+ }
121
+ /** Why a runs_on value is refused, or undefined. */
122
+ function runsOnError(value) {
123
+ return isIdentityString(value) ? undefined : `runs_on must be <handle>@<machine> (lowercase letters, digits, ".", "_", "-"), got "${value}"`;
124
+ }
105
125
  export async function listRoutines(slug) {
106
126
  let raw;
107
127
  try {
@@ -117,7 +137,7 @@ export async function listRoutines(slug) {
117
137
  .filter(Boolean);
118
138
  }
119
139
  async function writeRoutines(slug, routines) {
120
- await fs.writeFile(await routinesFile(slug), stringify(routines));
140
+ await fs.writeFile(await routinesFile(slug), stringify(routines.map(serializeRoutine)));
121
141
  }
122
142
  export async function saveRoutine(slug, input) {
123
143
  const name = input.name?.trim();
@@ -134,15 +154,26 @@ export async function saveRoutine(slug, input) {
134
154
  .slice(0, 48);
135
155
  if (!id)
136
156
  throw new Error("name must contain at least one letter or digit");
157
+ const given = typeof input.runsOn === "string" ? input.runsOn.trim() : input.runsOn;
158
+ if (given) {
159
+ const err = runsOnError(given);
160
+ if (err)
161
+ throw new Error(err);
162
+ }
163
+ const routines = await listRoutines(slug);
164
+ const i = routines.findIndex((r) => r.id === id);
165
+ const existing = i >= 0 ? routines[i] : undefined;
166
+ const runsOn = given === undefined ? (existing ? existing.runsOn : await localIdentity()) : given || undefined;
167
+ const priv = input.private ?? existing?.private;
137
168
  const routine = {
138
169
  id,
139
170
  name,
140
171
  schedule: input.schedule.trim(),
141
172
  prompt: input.prompt,
142
173
  enabled: input.enabled !== false,
174
+ ...(runsOn ? { runsOn } : {}),
175
+ ...(priv !== undefined ? { private: priv } : {}),
143
176
  };
144
- const routines = await listRoutines(slug);
145
- const i = routines.findIndex((r) => r.id === id);
146
177
  if (i >= 0)
147
178
  routines[i] = routine;
148
179
  else
@@ -191,13 +222,19 @@ export async function allRoutines(workflows) {
191
222
  }))));
192
223
  return per.flat();
193
224
  }
225
+ /** Whether a routine fires on the machine whose identity is `me`: enabled and runs_on exactly that. */
226
+ export const firesHere = (r, me) => r.enabled && r.runsOn === me;
194
227
  export async function routineStatuses(slug) {
195
- const [routines, state] = await Promise.all([listRoutines(slug), readState()]);
228
+ const [routines, state, id] = await Promise.all([listRoutines(slug), readState(), readIdentity()]);
229
+ const me = identityString(id);
196
230
  return routines.map((r) => {
197
231
  const s = state[`${slug}/${r.id}`] ?? {};
232
+ const runner = !r.runsOn ? "unassigned" : r.runsOn === me ? "here" : "elsewhere";
198
233
  return {
199
234
  ...r,
200
- ...(r.enabled ? { nextRunAt: nextRun(r.schedule) } : {}),
235
+ runner,
236
+ ...(r.runsOn && id.previous.includes(r.runsOn) ? { retired: true } : {}),
237
+ ...(firesHere(r, me) ? { nextRunAt: nextRun(r.schedule) } : {}),
201
238
  ...(s.lastRunAt ? { lastRunAt: s.lastRunAt } : {}),
202
239
  ...(s.lastChatId ? { lastChatId: s.lastChatId } : {}),
203
240
  ...(s.lastChatId && chatAwaitingApproval(s.lastChatId) ? { awaitingApproval: true } : {}),
@@ -254,48 +291,152 @@ export async function runRoutineNow(slug, id) {
254
291
  /* ---------- scheduler ---------- */
255
292
  /** The scheduler's timer, one per workspace. */
256
293
  const scheduler = perWorkspace(() => ({ timer: null }));
294
+ /**
295
+ * The routines due at `now` that fire on this machine: enabled, runs_on
296
+ * exactly this identity, cron matching. Everything else — unassigned,
297
+ * another machine's, a retired identity's — is never due here.
298
+ */
299
+ export async function dueRoutines(now, me) {
300
+ const identity = me ?? (await localIdentity());
301
+ const out = [];
302
+ for (const m of await listAgents()) {
303
+ for (const r of await listRoutines(m.slug).catch(() => [])) {
304
+ if (!firesHere(r, identity))
305
+ continue;
306
+ try {
307
+ if (cronMatches(parseCron(r.schedule), now))
308
+ out.push({ slug: m.slug, routine: r });
309
+ }
310
+ catch {
311
+ // invalid schedule — surfaced by validation on save, skip here
312
+ }
313
+ }
314
+ }
315
+ return out;
316
+ }
257
317
  async function tick() {
258
318
  const now = new Date();
259
319
  const p = (n) => String(n).padStart(2, "0");
260
320
  const minuteKey = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
261
- let agents;
321
+ let due;
262
322
  try {
263
- agents = await listAgents();
323
+ due = await dueRoutines(now);
264
324
  }
265
325
  catch {
266
326
  return;
267
327
  }
328
+ if (!due.length)
329
+ return;
268
330
  const state = await readState();
269
331
  let dirty = false;
270
- for (const m of agents) {
271
- for (const r of await listRoutines(m.slug).catch(() => [])) {
272
- if (!r.enabled)
273
- continue;
274
- let due = false;
275
- try {
276
- due = cronMatches(parseCron(r.schedule), now);
332
+ for (const { slug, routine: r } of due) {
333
+ const key = `${slug}/${r.id}`;
334
+ if (state[key]?.lastMinute === minuteKey)
335
+ continue;
336
+ state[key] = { ...state[key], lastMinute: minuteKey };
337
+ dirty = true;
338
+ try {
339
+ const chatId = await fireRoutine(slug, r);
340
+ state[key] = { ...state[key], lastRunAt: now.toISOString(), lastChatId: chatId };
341
+ delete state[key].lastError;
342
+ }
343
+ catch (err) {
344
+ state[key] = { ...state[key], lastError: err.message };
345
+ void notifyRoutineFailed(slug, r, err.message);
346
+ }
347
+ }
348
+ if (dirty)
349
+ await writeState(state);
350
+ }
351
+ /** Every routine in the workspace that has no runner, or names a retired identity of this machine. */
352
+ export async function runnerFindings() {
353
+ const id = await readIdentity();
354
+ const out = { unassigned: [], retired: [] };
355
+ let since = 0;
356
+ // Archived agents included: the scheduler fires their routines too.
357
+ for (const a of await listAgents()) {
358
+ const routines = await listRoutines(a.slug).catch(() => []);
359
+ let hit = false;
360
+ for (const r of routines) {
361
+ if (!r.runsOn && r.enabled) {
362
+ out.unassigned.push({ agent: a.slug, id: r.id, name: r.name });
363
+ hit = true;
277
364
  }
278
- catch {
279
- continue; // invalid schedule — surfaced by validation on save, skip here
365
+ else if (r.runsOn && id.previous.includes(r.runsOn)) {
366
+ out.retired.push({ agent: a.slug, id: r.id, name: r.name, from: r.runsOn });
367
+ hit = true;
280
368
  }
281
- const key = `${m.slug}/${r.id}`;
282
- if (!due || state[key]?.lastMinute === minuteKey)
369
+ }
370
+ if (hit) {
371
+ const st = await fs.stat(await routinesFile(a.slug)).catch(() => null);
372
+ if (st && st.mtimeMs > since)
373
+ since = st.mtimeMs;
374
+ }
375
+ }
376
+ if (since)
377
+ out.since = new Date(since).toISOString();
378
+ return out;
379
+ }
380
+ /**
381
+ * "Run these here": make this machine the runner of the routines `pick`
382
+ * selects, write routines.yml and commit just the files it touched — the
383
+ * commit is what tells the other clones who runs them. A workspace without
384
+ * git sync, or a commit that fails, keeps the written files and says why.
385
+ */
386
+ export async function adoptRoutines(pick, message) {
387
+ const id = await readIdentity();
388
+ const me = identityString(id);
389
+ const adopted = [];
390
+ const written = [];
391
+ for (const a of await listAgents()) {
392
+ const routines = await listRoutines(a.slug).catch(() => []);
393
+ let changed = false;
394
+ for (const r of routines) {
395
+ if (r.runsOn === me || !pick(a.slug, r, id.previous))
283
396
  continue;
284
- state[key] = { ...state[key], lastMinute: minuteKey };
285
- dirty = true;
286
- try {
287
- const chatId = await fireRoutine(m.slug, r);
288
- state[key] = { ...state[key], lastRunAt: now.toISOString(), lastChatId: chatId };
289
- delete state[key].lastError;
290
- }
291
- catch (err) {
292
- state[key] = { ...state[key], lastError: err.message };
293
- void notifyRoutineFailed(m.slug, r, err.message);
294
- }
397
+ adopted.push({ agent: a.slug, id: r.id, name: r.name, ...(r.runsOn ? { from: r.runsOn } : {}) });
398
+ r.runsOn = me;
399
+ changed = true;
400
+ }
401
+ if (changed) {
402
+ await writeRoutines(a.slug, routines);
403
+ written.push(await routinesFile(a.slug));
295
404
  }
296
405
  }
297
- if (dirty)
298
- await writeState(state);
406
+ const root = workspaceRoot();
407
+ const files = written.map((f) => path.relative(root, f).split(path.sep).join("/"));
408
+ if (!written.length)
409
+ return { runsOn: me, adopted, files, commit: null };
410
+ return { runsOn: me, adopted, files, commit: await commitFiles(written, message ?? `Routines run on ${me}: ${adopted.map((x) => `${x.agent}/${x.id}`).join(", ")}`) };
411
+ }
412
+ async function commitFiles(abs, message) {
413
+ const repoRoot = await repoRootFor(path.dirname(abs[0]));
414
+ if (!repoRoot)
415
+ return { ok: false, error: "the workspace is not a git repository" };
416
+ // rev-parse answers with symlinks resolved (/var → /private/var on macOS); the files must be spelled the same way.
417
+ const real = (p) => {
418
+ try {
419
+ return realpathSync.native(p);
420
+ }
421
+ catch {
422
+ return path.resolve(p);
423
+ }
424
+ };
425
+ const rel = abs.map((f) => path.relative(repoRoot, real(f)).split(path.sep).join("/"));
426
+ return gitCommit(rel, message);
427
+ }
428
+ /** Adopt one routine (whatever its runs_on said). */
429
+ export async function adoptRoutine(slug, id) {
430
+ const safe = safeAgentSlug(slug);
431
+ if (!(await listRoutines(safe)).some((r) => r.id === id))
432
+ throw new Error("routine not found");
433
+ const result = await adoptRoutines((agent, r) => agent === safe && r.id === id);
434
+ const routine = (await routineStatuses(safe)).find((r) => r.id === id);
435
+ return { ...result, routine };
436
+ }
437
+ /** Adopt every enabled routine without runs_on — and, with `retired`, every one naming a retired identity of this machine. */
438
+ export function adoptUnassigned(opts = {}) {
439
+ return adoptRoutines((_agent, r, previous) => (!r.runsOn && r.enabled) || (!!opts.retired && !!r.runsOn && previous.includes(r.runsOn)));
299
440
  }
300
441
  /** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
301
442
  export function startRoutineScheduler() {
@@ -5,7 +5,9 @@ export declare const agentRoutes: (import("./router.js").Route<"agents.addJourna
5
5
  }, "/api/agents/:slug/journal", z.ZodObject<{
6
6
  entry: z.ZodDefault<z.ZodString>;
7
7
  kind: z.ZodOptional<z.ZodString>;
8
- }, z.core.$strip>> | import("./router.js").Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
8
+ }, z.core.$strip>> | import("./router.js").Route<"agents.adoptRoutine", import("../../core/routines.js").AdoptResult & {
9
+ routine: import("../../core/routines.js").RoutineStatus;
10
+ }, "/api/agents/:slug/routines/:id/adopt", undefined> | import("./router.js").Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
9
11
  archived: z.ZodDefault<z.ZodBoolean>;
10
12
  }, z.core.$strip>> | import("./router.js").Route<"agents.create", import("../../core/agents.js").AgentDetail, "/api/agents", z.ZodObject<{
11
13
  name: z.ZodOptional<z.ZodString>;
@@ -56,6 +58,8 @@ export declare const agentRoutes: (import("./router.js").Route<"agents.addJourna
56
58
  schedule: z.ZodOptional<z.ZodString>;
57
59
  prompt: z.ZodOptional<z.ZodString>;
58
60
  enabled: z.ZodOptional<z.ZodBoolean>;
61
+ runsOn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
62
+ private: z.ZodOptional<z.ZodBoolean>;
59
63
  }, z.core.$strip>> | import("./router.js").Route<"agents.saveSkill", import("../../core/skills.js").SkillInfo & {
60
64
  content: string;
61
65
  }, "/api/agents/:slug/skills", z.ZodObject<{
@@ -2,7 +2,7 @@ import * as z from "zod";
2
2
  import { agentsRoot, deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived } from "../../core/agents.js";
3
3
  import { appendJournal, readJournal } from "../../core/journal.js";
4
4
  import { UI_ACTOR } from "../../core/knowledge.js";
5
- import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine } from "../../core/routines.js";
5
+ import { adoptRoutine, deleteRoutine, listRoutines, routineStatuses, runRoutineNow, saveRoutine } from "../../core/routines.js";
6
6
  import { deleteAgentSkill, listAgentSkills, readSkill, saveAgentSkill } from "../../core/skills.js";
7
7
  import { fail, route } from "./router.js";
8
8
  /** A member's editable fields. Loose: saveAgent checks values (name, harness, effort) and keeps what it knows. */
@@ -21,13 +21,19 @@ const AgentFields = z.looseObject({
21
21
  skills: z.array(z.string()).optional(),
22
22
  system: z.string().optional(),
23
23
  });
24
- /** A routine: every field saveRoutine stores (it builds the routine from these alone), so a strict object loses nothing. saveRoutine checks name, prompt and the cron schedule. */
24
+ /**
25
+ * A routine: every field saveRoutine stores (it builds the routine from these alone), so a strict object loses nothing.
26
+ * saveRoutine checks name, prompt, the cron schedule and runsOn. runsOn absent keeps the stored runner (a new routine:
27
+ * this machine); null or "" unassigns it.
28
+ */
25
29
  const RoutineFields = z.object({
26
30
  id: z.string().optional(),
27
31
  name: z.string().optional(),
28
32
  schedule: z.string().optional(),
29
33
  prompt: z.string().optional(),
30
34
  enabled: z.boolean().optional(),
35
+ runsOn: z.string().nullable().optional(),
36
+ private: z.boolean().optional(),
31
37
  });
32
38
  /** Agents (team members): definition, own skills, journal, routines. */
33
39
  export const agentRoutes = [
@@ -92,5 +98,16 @@ export const agentRoutes = [
92
98
  await deleteRoutine(c.params.slug, c.params.id);
93
99
  return { ok: true };
94
100
  }),
101
+ route({
102
+ name: "agents.adoptRoutine",
103
+ method: "POST",
104
+ path: "/api/agents/:slug/routines/:id/adopt",
105
+ summary: "run this routine here: runs_on = this machine's identity, routines.yml committed",
106
+ errors: 400,
107
+ }, async (c) => {
108
+ if (!(await listRoutines(c.params.slug)).some((r) => r.id === c.params.id))
109
+ fail(404, "routine not found");
110
+ return adoptRoutine(c.params.slug, c.params.id);
111
+ }),
95
112
  route({ name: "agents.runRoutine", method: "POST", path: "/api/agents/:slug/routines/:id/run", summary: "run a routine now", errors: 400 }, async (c) => runRoutineNow(c.params.slug, c.params.id)),
96
113
  ];
@@ -27,7 +27,9 @@ export declare const routes: (Route<"agents.addJournal", {
27
27
  }, "/api/agents/:slug/journal", z.ZodObject<{
28
28
  entry: z.ZodDefault<z.ZodString>;
29
29
  kind: z.ZodOptional<z.ZodString>;
30
- }, z.core.$strip>> | Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
30
+ }, z.core.$strip>> | Route<"agents.adoptRoutine", import("../../core/routines.js").AdoptResult & {
31
+ routine: import("../../core/routines.js").RoutineStatus;
32
+ }, "/api/agents/:slug/routines/:id/adopt", undefined> | Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
31
33
  archived: z.ZodDefault<z.ZodBoolean>;
32
34
  }, z.core.$strip>> | Route<"agents.create", import("../../core/agents.js").AgentDetail, "/api/agents", z.ZodObject<{
33
35
  name: z.ZodOptional<z.ZodString>;
@@ -78,6 +80,8 @@ export declare const routes: (Route<"agents.addJournal", {
78
80
  schedule: z.ZodOptional<z.ZodString>;
79
81
  prompt: z.ZodOptional<z.ZodString>;
80
82
  enabled: z.ZodOptional<z.ZodBoolean>;
83
+ runsOn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
84
+ private: z.ZodOptional<z.ZodBoolean>;
81
85
  }, z.core.$strip>> | Route<"agents.saveSkill", import("../../core/skills.js").SkillInfo & {
82
86
  content: string;
83
87
  }, "/api/agents/:slug/skills", z.ZodObject<{
@@ -399,7 +403,12 @@ export declare const routes: (Route<"agents.addJournal", {
399
403
  aliasPort?: number | undefined;
400
404
  }, "/api/hub/open", z.ZodObject<{
401
405
  root: z.ZodString;
402
- }, z.core.$strip>> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
406
+ }, z.core.$strip>> | Route<"identity.get", {
407
+ handle: string;
408
+ machine: string;
409
+ identity: string;
410
+ previous: string[];
411
+ }, "/api/identity", undefined> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
403
412
  name: z.ZodDefault<z.ZodString>;
404
413
  }, z.core.$strip>> | Route<"knowledge.delete", {
405
414
  ok: boolean;
@@ -497,7 +506,9 @@ export declare const routes: (Route<"agents.addJournal", {
497
506
  error?: string;
498
507
  off?: boolean;
499
508
  repo?: import("../../core/repos.js").RepoInfo;
500
- }>, "/api/repos/:slug/update", undefined> | Route<"routines.list", {
509
+ }>, "/api/repos/:slug/update", undefined> | Route<"routines.adopt", import("../../core/routines.js").AdoptResult, "/api/routines/adopt", z.ZodObject<{
510
+ retired: z.ZodDefault<z.ZodBoolean>;
511
+ }, z.core.$strip>> | Route<"routines.list", {
501
512
  routines: (import("../../core/routines.js").RoutineStatus & {
502
513
  agent: {
503
514
  slug: string;