@netnodeag/kraftwerk 0.14.2 → 0.16.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.
package/README.md CHANGED
@@ -96,6 +96,16 @@ directory, default `output/`), `knowledge:` (OKF knowledge-bundle root,
96
96
  default `knowledge/`), and `agents:` (team agent-definition root, default
97
97
  `agents/`).
98
98
 
99
+ `switcher:` links other kraftwerk workspaces from the inspector header —
100
+ the workspace name becomes a dropdown listing them:
101
+
102
+ ```yaml
103
+ switcher:
104
+ - name: other space
105
+ url: https://localhost:1985
106
+ icon: "🛰" # optional
107
+ ```
108
+
99
109
  ### Triggering from CI / cron / webhooks
100
110
 
101
111
  `run --json` is the machine mode: non-interactive, one JSON result object
package/dist/config.d.ts CHANGED
@@ -17,12 +17,25 @@
17
17
  * knowledge: knowledge # OKF knowledge-bundle root, relative to the file
18
18
  * agents: agents # team agent-definition root, relative to the file
19
19
  * skills: skills # workspace skill root, relative to the file
20
+ * switcher: # other kraftwerk workspaces, linked from the header dropdown
21
+ * - name: other space
22
+ * url: https://localhost:1985
23
+ * icon: "🛰" # optional emoji shown next to the entry
20
24
  */
21
25
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
22
26
  export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
23
27
  /** Stable, versionless URL of the kraftwerk.yml JSON schema (editor validation). */
24
28
  export declare const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/kraftwerk.schema.json";
25
29
  export declare const CONFIG_FILENAMES: string[];
30
+ /** One entry of the workspace switcher: another kraftwerk instance to link to. */
31
+ export interface SwitcherEntry {
32
+ /** Display name of the other workspace. */
33
+ name: string;
34
+ /** URL the other workspace's inspector runs on, e.g. https://localhost:1985 */
35
+ url: string;
36
+ /** Optional emoji shown next to the entry. */
37
+ icon?: string;
38
+ }
26
39
  export interface ProjectConfig {
27
40
  /** Display name of the project ("environment"), shown in the inspector header. */
28
41
  name?: string;
@@ -40,6 +53,8 @@ export interface ProjectConfig {
40
53
  agents?: string;
41
54
  /** Workspace skill root relative to the project root. Default: skills */
42
55
  skills?: string;
56
+ /** Other kraftwerk workspaces, shown as a switcher dropdown in the inspector header. */
57
+ switcher?: SwitcherEntry[];
43
58
  }
44
59
  export interface Project {
45
60
  /** Absolute project root the CLI operates on. */
package/dist/config.js CHANGED
@@ -20,6 +20,10 @@ import { parse } from "yaml";
20
20
  * knowledge: knowledge # OKF knowledge-bundle root, relative to the file
21
21
  * agents: agents # team agent-definition root, relative to the file
22
22
  * skills: skills # workspace skill root, relative to the file
23
+ * switcher: # other kraftwerk workspaces, linked from the header dropdown
24
+ * - name: other space
25
+ * url: https://localhost:1985
26
+ * icon: "🛰" # optional emoji shown next to the entry
23
27
  */
24
28
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
25
29
  export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -89,10 +93,10 @@ async function loadConfig(configPath) {
89
93
  if (raw === null || raw === undefined)
90
94
  return {};
91
95
  if (typeof raw !== "object" || Array.isArray(raw)) {
92
- throw new Error(`${path.basename(configPath)}: expected a mapping (name, icon, port, workflows, output, knowledge, agents, skills)`);
96
+ throw new Error(`${path.basename(configPath)}: expected a mapping (name, icon, port, workflows, output, knowledge, agents, skills, switcher)`);
93
97
  }
94
98
  const config = raw;
95
- const known = ["name", "icon", "port", "workflows", "output", "knowledge", "agents", "skills"];
99
+ const known = ["name", "icon", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher"];
96
100
  for (const key of Object.keys(config)) {
97
101
  if (!known.includes(key)) {
98
102
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
@@ -102,9 +106,38 @@ async function loadConfig(configPath) {
102
106
  throw new Error(`${path.basename(configPath)}: port must be an integer`);
103
107
  }
104
108
  }
109
+ else if (key === "switcher") {
110
+ validateSwitcher(configPath, config[key]);
111
+ }
105
112
  else if (typeof config[key] !== "string") {
106
113
  throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
107
114
  }
108
115
  }
109
116
  return config;
110
117
  }
118
+ function validateSwitcher(configPath, value) {
119
+ const file = path.basename(configPath);
120
+ if (!Array.isArray(value)) {
121
+ throw new Error(`${file}: switcher must be a list of { name, url, icon? } entries`);
122
+ }
123
+ value.forEach((entry, i) => {
124
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
125
+ throw new Error(`${file}: switcher[${i}] must be a mapping with name and url`);
126
+ }
127
+ const e = entry;
128
+ for (const key of Object.keys(e)) {
129
+ if (!["name", "url", "icon"].includes(key)) {
130
+ throw new Error(`${file}: switcher[${i}]: unknown key "${key}" (allowed: name, url, icon)`);
131
+ }
132
+ }
133
+ if (typeof e.name !== "string" || !e.name.trim()) {
134
+ throw new Error(`${file}: switcher[${i}]: name must be a non-empty string`);
135
+ }
136
+ if (typeof e.url !== "string" || !/^https?:\/\//.test(e.url)) {
137
+ throw new Error(`${file}: switcher[${i}]: url must be an http(s) URL`);
138
+ }
139
+ if (e.icon !== undefined && typeof e.icon !== "string") {
140
+ throw new Error(`${file}: switcher[${i}]: icon must be a string`);
141
+ }
142
+ });
143
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
2
+ /** Server shutdown: no agent subprocess may outlive the inspector. */
3
+ export declare function disposeAllBackends(): void;
2
4
  export declare function createChat(opts: {
3
5
  agent: ChatAgentId;
4
6
  scope: ChatScope;
@@ -10,6 +10,32 @@ import { startAcpBackend } from "./acp.js";
10
10
  import { startPiBackend } from "./pi.js";
11
11
  import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
12
12
  const states = new Map();
13
+ /** Kill a chat's agent subprocess; the next message spawns a fresh one. */
14
+ function dropBackend(state) {
15
+ state.backend?.dispose();
16
+ state.backend = null;
17
+ }
18
+ // Idle reaper: a finished chat must not keep its agent subprocess alive
19
+ // forever (one ACP adapter + one harness binary per chat adds up fast —
20
+ // hourly routines used to leak a process pair per tick). History is on
21
+ // disk and ensureBackend re-sends scope context, so reaping just costs
22
+ // the next message one respawn. busy=true covers the whole turn including
23
+ // pending permission prompts, so nothing is killed mid-work.
24
+ const IDLE_BACKEND_MS = 15 * 60_000;
25
+ const reaper = setInterval(() => {
26
+ const cutoff = Date.now() - IDLE_BACKEND_MS;
27
+ for (const state of states.values()) {
28
+ if (state.backend && !state.busy && Date.parse(state.meta.updatedAt) < cutoff) {
29
+ dropBackend(state);
30
+ }
31
+ }
32
+ }, 60_000);
33
+ reaper.unref?.();
34
+ /** Server shutdown: no agent subprocess may outlive the inspector. */
35
+ export function disposeAllBackends() {
36
+ for (const state of states.values())
37
+ dropBackend(state);
38
+ }
13
39
  async function loadState(id) {
14
40
  const existing = states.get(id);
15
41
  if (existing)
@@ -387,13 +413,17 @@ export async function postMessage(id, text) {
387
413
  const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
388
414
  const stopReason = await backend.prompt(promptText);
389
415
  emit(state, { type: "turn_end", stopReason });
416
+ // Routine sessions are one-shot and unattended: release the agent
417
+ // process as soon as the turn ends instead of waiting for the reaper.
418
+ const scope = state.meta.scope;
419
+ if (scope.kind === "team" && scope.routine)
420
+ dropBackend(state);
390
421
  }
391
422
  catch (err) {
392
423
  emit(state, { type: "error", message: err.message });
393
424
  // A failed turn may mean a dead subprocess; drop it so the next
394
425
  // message spawns a fresh agent (thread history stays on disk).
395
- state.backend?.dispose();
396
- state.backend = null;
426
+ dropBackend(state);
397
427
  }
398
428
  finally {
399
429
  state.busy = false;
@@ -419,7 +449,7 @@ export async function deleteChat(id) {
419
449
  return { error: "not found" };
420
450
  for (const resolve of state.pendingPermissions.values())
421
451
  resolve(null);
422
- state.backend?.dispose();
452
+ dropBackend(state);
423
453
  states.delete(id);
424
454
  await fs.rm(safeChatDir(id), { recursive: true, force: true });
425
455
  return {};
@@ -6,7 +6,7 @@ import { resolveProject } from "../config.js";
6
6
  import { listRuns, getRun, readRunFile } from "./runs.js";
7
7
  import { listWorkflows, getWorkflow } from "./workflows.js";
8
8
  import { dockerStatus, triggerRun, stopRun } from "./runner.js";
9
- import { cancelChat, createChat, deleteChat, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
9
+ import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
10
10
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
11
  import { deleteMember, getMember, listMembers, saveMember, teamRoot, } from "./team.js";
12
12
  import { getSkill, listSkills, skillsRoot } from "./skills.js";
@@ -76,7 +76,12 @@ async function handleApi(req, res, url) {
76
76
  if (seg.length === 2 && seg[1] === "meta" && method === "GET") {
77
77
  const project = await resolveProject(getProjectRoot()).catch(() => null);
78
78
  const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
79
- return json(res, { version: await getPkgVersion(), projectName, projectIcon: project?.config.icon ?? "" });
79
+ return json(res, {
80
+ version: await getPkgVersion(),
81
+ projectName,
82
+ projectIcon: project?.config.icon ?? "",
83
+ switcher: project?.config.switcher ?? [],
84
+ });
80
85
  }
81
86
  // GET /api/runs
82
87
  if (seg.length === 2 && seg[1] === "runs" && method === "GET") {
@@ -483,6 +488,15 @@ export function startInspector(opts) {
483
488
  if (opts.projectRoot)
484
489
  setProjectRoot(opts.projectRoot);
485
490
  startRoutineScheduler();
491
+ // Chat agent subprocesses must die with the server — signals bypass
492
+ // "exit" handlers, so hook the signals themselves.
493
+ for (const sig of ["SIGINT", "SIGTERM"]) {
494
+ process.once(sig, () => {
495
+ disposeAllBackends();
496
+ process.exit(sig === "SIGINT" ? 130 : 143);
497
+ });
498
+ }
499
+ process.once("exit", disposeAllBackends);
486
500
  const server = http.createServer(async (req, res) => {
487
501
  try {
488
502
  const url = new URL(req.url ?? "/", "http://localhost");