@netnodeag/kraftwerk 0.80.0 → 0.81.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 (44) hide show
  1. package/README.md +5 -2
  2. package/dist/cli/init.js +3 -2
  3. package/dist/cli/kraftwerk.js +3 -0
  4. package/dist/cli/temp.d.ts +2 -0
  5. package/dist/cli/temp.js +62 -0
  6. package/dist/client/routes.d.ts +2 -0
  7. package/dist/client/routes.js +2 -0
  8. package/dist/core/chat/acp.js +2 -1
  9. package/dist/core/chat/backend.d.ts +2 -0
  10. package/dist/core/chat/context.js +11 -6
  11. package/dist/core/chat/pi.js +2 -1
  12. package/dist/core/chat/seats.js +3 -0
  13. package/dist/core/chat/sessions.js +3 -0
  14. package/dist/core/files.js +1 -0
  15. package/dist/core/projects.js +1 -1
  16. package/dist/core/temp.d.ts +73 -0
  17. package/dist/core/temp.js +154 -0
  18. package/dist/harness.d.ts +2 -0
  19. package/dist/harnesses/acp.js +1 -1
  20. package/dist/harnesses/claude.js +1 -0
  21. package/dist/harnesses/codex.js +1 -0
  22. package/dist/harnesses/pi.js +1 -0
  23. package/dist/run.d.ts +7 -0
  24. package/dist/run.js +13 -1
  25. package/dist/server/api/files.d.ts +9 -1
  26. package/dist/server/api/files.js +13 -0
  27. package/dist/server/api/index.d.ts +9 -1
  28. package/dist/yaml.js +1 -0
  29. package/package.json +1 -1
  30. package/web/dist/assets/{dist-1PajaYPg.js → dist-8IOFzZx-.js} +1 -1
  31. package/web/dist/assets/{dist-K_3Y5Nbl.js → dist-AhnSw4hy.js} +1 -1
  32. package/web/dist/assets/{dist-B4dqwo7_.js → dist-BK0VhAPQ.js} +1 -1
  33. package/web/dist/assets/{dist-CK2u1OXb.js → dist-Ba6WSVOB.js} +1 -1
  34. package/web/dist/assets/{dist-NX4_yvho.js → dist-BiU4F98O.js} +1 -1
  35. package/web/dist/assets/{dist-DlZPeneT.js → dist-Dx3aajX1.js} +1 -1
  36. package/web/dist/assets/{dist-D-f0nszo.js → dist-Dy1SvhKC.js} +1 -1
  37. package/web/dist/assets/{dist-u_oAKIhZ.js → dist-ENZYo2jZ.js} +1 -1
  38. package/web/dist/assets/{dist-DgKgeYFJ.js → dist-FfKYtQxT.js} +1 -1
  39. package/web/dist/assets/{dist-DKduisGW.js → dist-cewmjxFi.js} +1 -1
  40. package/web/dist/assets/dist-lgUK2Fjt.js +1 -0
  41. package/web/dist/assets/{editor-CE9TlKkJ.js → editor-CaRa1NMC.js} +3 -3
  42. package/web/dist/assets/{index-IAuwD22j.js → index-Dpzux3OC.js} +3 -3
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/assets/dist-Bp1iMuQ7.js +0 -1
package/README.md CHANGED
@@ -300,7 +300,9 @@ Optional, at the project root, and also the root marker for the walk-up. All
300
300
  fields are optional. `workflows:` sets the workflows root, `output:` the
301
301
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
302
302
  (default `knowledge/`), and `agents:` the agent-definition root (default
303
- `agents/`). `repos:` turns on the [repositories](#repositories) folder.
303
+ `agents/`). `repos:` turns on the [repositories](#repositories) folder,
304
+ `projects:` the [projects](#projects), and `contacts:`, `places:` and
305
+ `events:` each one of the [contacts, places and events](#contacts-places-and-events).
304
306
  `cloud: { enabled: false }` leaves the workspace out of this machine's
305
307
  record in the [kraftwerk cloud](#this-machine-in-the-kraftwerk-cloud).
306
308
 
@@ -342,7 +344,8 @@ projects (all but archived ones) and the channels of every workspace on this
342
344
  machine, grouped by workspace and running or not (a stopped one is started
343
345
  on the way), read from the workspace registry under `~/.kraftwerk/workspaces`,
344
346
  which every inspector keeps current with its roster, project and channel
345
- lists.
347
+ lists — and this workspace's contacts, places and events, by title, role,
348
+ organisation, address or date.
346
349
 
347
350
  ### Triggering from CI, cron, or webhooks
348
351
 
package/dist/cli/init.js CHANGED
@@ -139,11 +139,12 @@ export async function runInit(cwd) {
139
139
  created.push(bundleRel);
140
140
  }
141
141
  // .gitignore: the output dir, the repos root (clones must never become
142
- // gitlinks of the workspace repo) and .env (secrets). A missing file gets
142
+ // gitlinks of the workspace repo), the temp root (session scratch, which
143
+ // also carries a .gitignore of its own) and .env (secrets). A missing file gets
143
144
  // all in one write; an existing one only the entries it lacks.
144
145
  const gitignorePath = path.join(cwd, ".gitignore");
145
146
  const gitignore = (await readFile(gitignorePath, "utf8").catch(() => null)) ?? null;
146
- const entries = [`${DATA_DIR}/output/`, `${DATA_DIR}/repos/`, DOTENV_FILE];
147
+ const entries = [`${DATA_DIR}/output/`, `${DATA_DIR}/repos/`, `${DATA_DIR}/temp/`, DOTENV_FILE];
147
148
  if (gitignore === null) {
148
149
  await writeFile(gitignorePath, entries.map((e) => `${e}\n`).join(""));
149
150
  created.push(".gitignore");
@@ -18,6 +18,7 @@ import { registerWorkspaceCommands } from "./workspaces.js";
18
18
  import { registerRepoCommands } from "./repos.js";
19
19
  import { registerVibeableCommands } from "./vibeables.js";
20
20
  import { registerTrashCommands } from "./trash.js";
21
+ import { registerTempCommands } from "./temp.js";
21
22
  import { registerDevicesCommands } from "./devices.js";
22
23
  import { registerDaemonCommand } from "./daemon.js";
23
24
  import { registerRemoteCommand } from "./remote.js";
@@ -49,6 +50,7 @@ import { runUi } from "./ui.js";
49
50
  * kraftwerk repos ... repositories the agents work on (list/add/update/remove)
50
51
  * kraftwerk journal <agent> [entry] an agent's memory across sessions: print it, add a line
51
52
  * kraftwerk trash ... what was deleted: list, restore, purge, empty
53
+ * kraftwerk temp [clean] the sessions' scratch folders (TMPDIR of every agent): list, clean
52
54
  * kraftwerk devices ... paired devices: list, pair one, revoke one
53
55
  * kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
54
56
  * kraftwerk cloud [on|off|status] this machine in the kraftwerk cloud (the daemon registers it)
@@ -309,6 +311,7 @@ registerRefCommand(program);
309
311
  registerRepoCommands(program);
310
312
  registerVibeableCommands(program);
311
313
  registerTrashCommands(program);
314
+ registerTempCommands(program);
312
315
  registerDevicesCommands(program);
313
316
  registerDaemonCommand(program);
314
317
  registerRemoteCommand(program);
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerTempCommands(program: Command): void;
@@ -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
+ }
@@ -136,6 +136,8 @@ export declare const ROUTES: {
136
136
  readonly "settings.save": readonly ["PUT", "/api/settings"];
137
137
  readonly "skills.get": readonly ["GET", "/api/skills/:name"];
138
138
  readonly "skills.list": readonly ["GET", "/api/skills"];
139
+ readonly "temp.clean": readonly ["DELETE", "/api/temp"];
140
+ readonly "temp.list": readonly ["GET", "/api/temp"];
139
141
  readonly "trash.empty": readonly ["DELETE", "/api/trash"];
140
142
  readonly "trash.list": readonly ["GET", "/api/trash"];
141
143
  readonly "trash.purge": readonly ["POST", "/api/trash/purge"];
@@ -137,6 +137,8 @@ export const ROUTES = {
137
137
  "settings.save": ["PUT", "/api/settings"],
138
138
  "skills.get": ["GET", "/api/skills/:name"],
139
139
  "skills.list": ["GET", "/api/skills"],
140
+ "temp.clean": ["DELETE", "/api/temp"],
141
+ "temp.list": ["GET", "/api/temp"],
140
142
  "trash.empty": ["DELETE", "/api/trash"],
141
143
  "trash.list": ["GET", "/api/trash"],
142
144
  "trash.purge": ["POST", "/api/trash/purge"],
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { RequestError, } from "@agentclientprotocol/sdk";
5
5
  import { adapterEnv, airMeta, connectAcp, EXT_METHODS, EXT_NOTIFICATIONS, fileChangeReportMeta, openSession, withAdapter, } from "../../acp.js";
6
+ import { tempEnv } from "../temp.js";
6
7
  import { unattendedMode } from "./permissions.js";
7
8
  /**
8
9
  * ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
@@ -389,7 +390,7 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
389
390
  };
390
391
  const { child, conn } = await connectAcp(agent, {
391
392
  cwd,
392
- env: adapterEnv(agent, tuning),
393
+ env: { ...adapterEnv(agent, tuning), ...(tuning.temp ? tempEnv(tuning.temp) : {}) },
393
394
  client,
394
395
  clientName: "kraftwerk-inspector",
395
396
  elicitation: true,
@@ -37,6 +37,8 @@ export interface BackendTuning {
37
37
  skillDirs?: string[];
38
38
  /** claude: extra directories the session may work in (e.g. project root for run chats). */
39
39
  addDirs?: string[];
40
+ /** The session's scratch folder (core/temp.ts): handed to the process as TMPDIR and KRAFTWERK_TEMP. */
41
+ temp?: string;
40
42
  /**
41
43
  * Nobody is watching this session live (routine runs). The backend selects
42
44
  * the harness's own hands-off permission mode — the harness keeps deciding
@@ -12,6 +12,7 @@ import { unattendedTimeoutLabel } from "./permissions.js";
12
12
  import { getChannel } from "../channels.js";
13
13
  import { JOURNAL_FILE, journalExcerpt, readJournal } from "../journal.js";
14
14
  import { filesSummary } from "../files.js";
15
+ import { sessionTemp, tempContext } from "../temp.js";
15
16
  /**
16
17
  * What an agent is told about where it works: the skills it may use and
17
18
  * the scope's context block (the workspace, a run, a project, a channel,
@@ -85,8 +86,8 @@ async function agentKnowledgeContext(def) {
85
86
  `part of your job. Consult them before answering questions in their domain, and keep them current ` +
86
87
  `when you learn something durable:\n${lines}\n\n` +
87
88
  `Read with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
88
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${def.slug}/${def.harness}\` ` +
89
- `(write the markdown to a temp file first; the CLI stamps provenance and maintains index.md/log.md). ` +
89
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor ${def.slug}/${def.harness}\` ` +
90
+ `(write the markdown into your temp folder first; the CLI stamps provenance and maintains index.md/log.md). ` +
90
91
  `Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
91
92
  `click in the UI.\n\n`);
92
93
  }
@@ -132,7 +133,8 @@ export async function agentFilesContext(def) {
132
133
  return (`## Files\nThe workspace's shared files (any format: brand assets, templates, contracts, …) are in ${ws.root}/ (${ws.count} file${ws.count === 1 ? "" : "s"}). ` +
133
134
  (linked ? `The folders that are part of your job:\n${linked}\n` : "") +
134
135
  `Work you do for a project belongs in that project's files folder, not here. What you produce in a direct session goes to ${ws.root}/inbox/. ` +
135
- `Delete nothing there unless asked.\n\n`);
136
+ `Only finished work and the user's own material belong in these folders — scratch, intermediate and downloaded files go to your ` +
137
+ `temp folder (below), never here. Delete nothing there unless asked.\n\n`);
136
138
  }
137
139
  /**
138
140
  * "## Your vibeables" block: the apps an agent builds and maintains, linked
@@ -310,7 +312,7 @@ async function baseScopeContext(scope, agent) {
310
312
  `- Favor structural markdown (headings, tables, lists) over prose.\n\n` +
311
313
  `ALWAYS write through the kraftwerk CLI so provenance is stamped and the bundle log/index stay maintained:\n` +
312
314
  `- \`npx kraftwerk knowledge init <bundle>\` — new bundle\n` +
313
- `- \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
315
+ `- \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
314
316
  `(write the markdown to a temp file first; the CLI stamps generated.by/at, appends log.md, regenerates index.md)\n` +
315
317
  `- \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`, \`validate\`\n` +
316
318
  `Do not hand-edit index.md or log.md (derived/maintained), and do not add \`verified\` yourself — ` +
@@ -349,7 +351,7 @@ async function baseScopeContext(scope, agent) {
349
351
  `## Recent runs\n${runLines || "(none)"}\n\n` +
350
352
  `## Knowledge\nOKF bundles in this project:\n${bundleLines || "(none)"}\n` +
351
353
  `Read with \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
352
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` ` +
354
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor kraftwerk-chat/${agent}\` ` +
353
355
  `(stamps provenance, maintains index.md/log.md — never hand-edit those).\n\n` +
354
356
  `## Agents\n${agentLines || "(none)"}\n` +
355
357
  `These are persistent agents (defined under agents/); the user talks to them on the ` +
@@ -403,7 +405,10 @@ export async function scopeContext(meta, seat) {
403
405
  // Who, where and when: the workspace's contacts, places and events, wherever the chat works (a run discusses its own artifacts only).
404
406
  scope.kind === "run" ? Promise.resolve("") : entitiesContext().catch(() => ""),
405
407
  ]);
406
- return [base, channel, project, repos, entities, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
408
+ // Every session has a scratch folder of its own (core/temp.ts), named by its absolute path: a run chat or a
409
+ // vibeable session works in another folder than the workspace root. The block comes after the files blocks it refers to.
410
+ const temp = await sessionTemp(meta.id).catch(() => null);
411
+ return [base, channel, project, repos, entities, vibe, temp ? tempContext(temp.abs) : "", RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
407
412
  }
408
413
  /** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
409
414
  async function channelContext(slug, self) {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { workspaceEnv } from "../env.js";
4
+ import { tempEnv } from "../temp.js";
4
5
  const clip = (raw) => {
5
6
  const oneLine = String(raw ?? "").replace(/\s+/g, " ").trim();
6
7
  return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
@@ -24,7 +25,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
24
25
  const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
25
26
  cwd,
26
27
  stdio: ["pipe", "pipe", "pipe"],
27
- env: workspaceEnv(),
28
+ env: { ...workspaceEnv(), ...(tuning.temp ? tempEnv(tuning.temp) : {}) },
28
29
  });
29
30
  child = proc;
30
31
  proc.stdin.write(text);
@@ -9,6 +9,7 @@ import { chatHref, dismissKey, pushNotification } from "../notifications.js";
9
9
  import { writeMeta } from "./store.js";
10
10
  import { availableSkills } from "./context.js";
11
11
  import { effectiveCwd } from "./sessions.js";
12
+ import { sessionTemp } from "../temp.js";
12
13
  import { emit, isUnattended, ownerLabel, seatAuthor } from "./state.js";
13
14
  /**
14
15
  * Starting an agent process for a seat: its harness, tuning (model,
@@ -57,6 +58,8 @@ async function backendTuning(meta, seat) {
57
58
  if (skills.length > 0)
58
59
  tuning.skillDirs = skills.map((s) => s.dir);
59
60
  }
61
+ // Every seat gets the chat's scratch folder as its temp dir (core/temp.ts), so nothing temporary lands in files/ or /tmp.
62
+ tuning.temp = (await sessionTemp(meta.id)).abs;
60
63
  return tuning;
61
64
  }
62
65
  export async function ensureBackend(state, seat) {
@@ -7,6 +7,7 @@ import { deleteAgentSession, listAgentSessions } from "./acp.js";
7
7
  import { chatHref, pushNotification } from "../notifications.js";
8
8
  import { readIdentity } from "../identity.js";
9
9
  import { moveToTrash } from "../trash.js";
10
+ import { removeSessionTemp } from "../temp.js";
10
11
  import { attachmentPath, copyChatFiles, listChatMetas, newChatId, readMetaAt, safeChatDir, writeMeta } from "./store.js";
11
12
  import { postChannelMessage } from "./channel-turns.js";
12
13
  import { expandSkillInvocation, scopeContext } from "./context.js";
@@ -526,6 +527,8 @@ export async function deleteChat(id) {
526
527
  dropBackend(state);
527
528
  chatStates().delete(id);
528
529
  await moveToTrash("chats", id, safeChatDir(id));
530
+ // Scratch, not a deliverable: gone for good with the chat.
531
+ await removeSessionTemp(id).catch(() => { });
529
532
  return {};
530
533
  }
531
534
  /** The agents' own transcripts of a chat folder (one in the trash), deleted for good. Best effort. */
@@ -312,5 +312,6 @@ export async function filesContext(scope, heading, intro) {
312
312
  return (`## ${heading}\n${intro} ${s.root}/ — ${s.count} file${s.count === 1 ? "" : "s"}` +
313
313
  (recent ? `, most recently changed:\n${recent}\n` : " (empty so far)\n") +
314
314
  `Read and write them there like any folder; put what you produce for the user in ${s.root}/deliverables/. ` +
315
+ `Only finished work and the material the user gave you belong here — scratch, intermediate and downloaded files go to your temp folder (see Temporary files), never into this folder. ` +
315
316
  `Files over 25 MB stay out of git automatically. Delete nothing there unless asked.`);
316
317
  }
@@ -441,7 +441,7 @@ export async function projectContext(slug, actor, opts = {}) {
441
441
  ? `## Knowledge\nOKF bundles linked to this project (markdown + YAML frontmatter under the knowledge root):\n` +
442
442
  linkLines("knowledge", (l) => `- ${l.slug}${l.label ? ` (${l.label})` : ""}`) +
443
443
  `\nRead with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
444
- `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${actor}\` ` +
444
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file $KRAFTWERK_TEMP/<name>.md --actor ${actor}\` ` +
445
445
  `(stamps provenance, maintains index.md/log.md — never hand-edit those).`
446
446
  : "",
447
447
  p.repos.length > 0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The temp folder: where a session's scratch work goes — intermediate
3
+ * results, downloads, build output, a markdown written for a CLI to read —
4
+ * instead of the system's temp dir or, worse, the workspace's files.
5
+ *
6
+ * kraftwerk-data/temp/<session-id>/ one folder per chat session
7
+ * <run dir>/temp/ a workflow run's (inside its mount)
8
+ *
9
+ * Three things make it stick, so it does not depend on an agent reading a
10
+ * sentence: the folder is handed to every spawned agent as TMPDIR (and
11
+ * TMP, TEMP, KRAFTWERK_TEMP), so mktemp, Python's tempfile, Node's
12
+ * os.tmpdir() and the agent's own scratchpad land there by themselves; it
13
+ * sits inside the workspace, so writes there need no approval from the
14
+ * harness; and the session prompt names it next to the files folders,
15
+ * which say in turn that nothing temporary belongs in them.
16
+ *
17
+ * The temp root is local: a .gitignore of its own keeps everything in it
18
+ * out of the workspace git (and `kraftwerk init` lists it too). Nothing in
19
+ * it is a deliverable, so nothing goes to the trash: a chat's folder is
20
+ * removed with the chat, and folders nobody touched for SWEEP_AFTER_MS are
21
+ * swept when a session starts (at most once an hour) or on demand
22
+ * (`kraftwerk temp clean`, DELETE /api/temp).
23
+ */
24
+ export declare const TEMP_DIR = "kraftwerk-data/temp";
25
+ /** A folder idle this long is swept. */
26
+ export declare const SWEEP_AFTER_MS: number;
27
+ export interface TempEntry {
28
+ /** The folder name: the session's id. */
29
+ id: string;
30
+ /** Relative to the workspace root, forward slashes. */
31
+ path: string;
32
+ files: number;
33
+ bytes: number;
34
+ /** The newest change inside (the folder's own when empty). */
35
+ modifiedAt: string;
36
+ }
37
+ export interface TempView {
38
+ root: string;
39
+ entries: TempEntry[];
40
+ bytes: number;
41
+ }
42
+ /** Absolute temp root of the current workspace. */
43
+ export declare function tempRoot(): Promise<string>;
44
+ /** The environment that points a process at `dir` for everything temporary. */
45
+ export declare function tempEnv(dir: string): Record<string, string>;
46
+ /** Create a folder outside the workspace's temp root (a workflow run's) and return the env for it. */
47
+ export declare function tempEnvFor(dir: string): Promise<Record<string, string>>;
48
+ /**
49
+ * A session's own folder, created: absolute path and the path relative to
50
+ * the workspace root (for the prompt). Starting a session is also when
51
+ * folders nobody uses any more are swept, at most once an hour.
52
+ */
53
+ export declare function sessionTemp(id: string): Promise<{
54
+ abs: string;
55
+ rel: string;
56
+ env: Record<string, string>;
57
+ }>;
58
+ /** Remove one session's folder for good (nothing there is a deliverable). */
59
+ export declare function removeSessionTemp(id: string): Promise<void>;
60
+ /** Every session folder in the temp root, newest first. */
61
+ export declare function listTemp(): Promise<TempView>;
62
+ /**
63
+ * Remove session folders: all of them, or those idle longer than
64
+ * `olderThanMs` (default SWEEP_AFTER_MS). `keep` names folders that stay
65
+ * whatever their age (the session being started). Returns what went.
66
+ */
67
+ export declare function cleanTemp(opts?: {
68
+ all?: boolean;
69
+ olderThanMs?: number;
70
+ keep?: string[];
71
+ }): Promise<TempEntry[]>;
72
+ /** The "## Temporary files" block of a session's context. */
73
+ export declare function tempContext(rel: string): string;
@@ -0,0 +1,154 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveWorkspace } from "../config.js";
4
+ import { workspaceRoot } from "./context.js";
5
+ import { perWorkspace } from "./workspace.js";
6
+ /**
7
+ * The temp folder: where a session's scratch work goes — intermediate
8
+ * results, downloads, build output, a markdown written for a CLI to read —
9
+ * instead of the system's temp dir or, worse, the workspace's files.
10
+ *
11
+ * kraftwerk-data/temp/<session-id>/ one folder per chat session
12
+ * <run dir>/temp/ a workflow run's (inside its mount)
13
+ *
14
+ * Three things make it stick, so it does not depend on an agent reading a
15
+ * sentence: the folder is handed to every spawned agent as TMPDIR (and
16
+ * TMP, TEMP, KRAFTWERK_TEMP), so mktemp, Python's tempfile, Node's
17
+ * os.tmpdir() and the agent's own scratchpad land there by themselves; it
18
+ * sits inside the workspace, so writes there need no approval from the
19
+ * harness; and the session prompt names it next to the files folders,
20
+ * which say in turn that nothing temporary belongs in them.
21
+ *
22
+ * The temp root is local: a .gitignore of its own keeps everything in it
23
+ * out of the workspace git (and `kraftwerk init` lists it too). Nothing in
24
+ * it is a deliverable, so nothing goes to the trash: a chat's folder is
25
+ * removed with the chat, and folders nobody touched for SWEEP_AFTER_MS are
26
+ * swept when a session starts (at most once an hour) or on demand
27
+ * (`kraftwerk temp clean`, DELETE /api/temp).
28
+ */
29
+ export const TEMP_DIR = "kraftwerk-data/temp";
30
+ /** A folder idle this long is swept. */
31
+ export const SWEEP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
32
+ const SWEEP_EVERY_MS = 60 * 60 * 1000;
33
+ /** A session id as a folder name: what newChatId() produces, nothing that could leave the root. */
34
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
35
+ async function resolvedRoot() {
36
+ const ws = await resolveWorkspace(workspaceRoot()).catch(() => null);
37
+ return ws?.root ?? workspaceRoot();
38
+ }
39
+ /** Absolute temp root of the current workspace. */
40
+ export async function tempRoot() {
41
+ return path.join(await resolvedRoot(), TEMP_DIR);
42
+ }
43
+ /** Create the root and keep it out of the workspace git: a .gitignore of its own ignores everything in it, itself included. */
44
+ async function prepare(root) {
45
+ await fs.mkdir(root, { recursive: true });
46
+ await fs.writeFile(path.join(root, ".gitignore"), "*\n", { flag: "wx" }).catch(() => { });
47
+ }
48
+ /** The environment that points a process at `dir` for everything temporary. */
49
+ export function tempEnv(dir) {
50
+ return { KRAFTWERK_TEMP: dir, TMPDIR: dir, TMP: dir, TEMP: dir };
51
+ }
52
+ /** Create a folder outside the workspace's temp root (a workflow run's) and return the env for it. */
53
+ export async function tempEnvFor(dir) {
54
+ await fs.mkdir(dir, { recursive: true });
55
+ return tempEnv(dir);
56
+ }
57
+ const lastSweep = perWorkspace(() => ({ at: 0 }));
58
+ /**
59
+ * A session's own folder, created: absolute path and the path relative to
60
+ * the workspace root (for the prompt). Starting a session is also when
61
+ * folders nobody uses any more are swept, at most once an hour.
62
+ */
63
+ export async function sessionTemp(id) {
64
+ if (!ID_RE.test(id))
65
+ throw new Error("invalid session id");
66
+ const root = await tempRoot();
67
+ await prepare(root);
68
+ const abs = path.join(root, id);
69
+ await fs.mkdir(abs, { recursive: true });
70
+ const sweep = lastSweep();
71
+ if (Date.now() - sweep.at > SWEEP_EVERY_MS) {
72
+ sweep.at = Date.now();
73
+ void cleanTemp({ keep: [id] }).catch(() => { });
74
+ }
75
+ return { abs, rel: `${TEMP_DIR}/${id}`, env: tempEnv(abs) };
76
+ }
77
+ /** Remove one session's folder for good (nothing there is a deliverable). */
78
+ export async function removeSessionTemp(id) {
79
+ if (!ID_RE.test(id))
80
+ return;
81
+ await fs.rm(path.join(await tempRoot(), id), { recursive: true, force: true });
82
+ }
83
+ /** Files, bytes and the newest mtime below a folder. */
84
+ async function measure(dir) {
85
+ const st = await fs.stat(dir);
86
+ const out = { files: 0, bytes: 0, newest: st.mtimeMs };
87
+ const walk = async (d) => {
88
+ if (out.files > 20000)
89
+ return;
90
+ for (const k of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
91
+ const abs = path.join(d, k.name);
92
+ if (k.isDirectory())
93
+ await walk(abs);
94
+ else if (k.isFile()) {
95
+ const s = await fs.stat(abs).catch(() => null);
96
+ if (!s)
97
+ continue;
98
+ out.files++;
99
+ out.bytes += s.size;
100
+ if (s.mtimeMs > out.newest)
101
+ out.newest = s.mtimeMs;
102
+ }
103
+ }
104
+ };
105
+ await walk(dir);
106
+ return out;
107
+ }
108
+ /** Every session folder in the temp root, newest first. */
109
+ export async function listTemp() {
110
+ const root = await tempRoot();
111
+ const kids = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
112
+ const entries = [];
113
+ for (const k of kids) {
114
+ if (!k.isDirectory() || k.name.startsWith("."))
115
+ continue;
116
+ const m = await measure(path.join(root, k.name)).catch(() => null);
117
+ if (!m)
118
+ continue;
119
+ entries.push({ id: k.name, path: `${TEMP_DIR}/${k.name}`, files: m.files, bytes: m.bytes, modifiedAt: new Date(m.newest).toISOString() });
120
+ }
121
+ entries.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
122
+ return { root, entries, bytes: entries.reduce((n, e) => n + e.bytes, 0) };
123
+ }
124
+ /**
125
+ * Remove session folders: all of them, or those idle longer than
126
+ * `olderThanMs` (default SWEEP_AFTER_MS). `keep` names folders that stay
127
+ * whatever their age (the session being started). Returns what went.
128
+ */
129
+ export async function cleanTemp(opts = {}) {
130
+ const view = await listTemp();
131
+ const cutoff = Date.now() - (opts.olderThanMs ?? SWEEP_AFTER_MS);
132
+ const keep = new Set(opts.keep ?? []);
133
+ const gone = [];
134
+ for (const e of view.entries) {
135
+ if (keep.has(e.id))
136
+ continue;
137
+ if (!opts.all && Date.parse(e.modifiedAt) > cutoff)
138
+ continue;
139
+ await fs.rm(path.join(view.root, e.id), { recursive: true, force: true });
140
+ gone.push(e);
141
+ }
142
+ return gone;
143
+ }
144
+ /** The "## Temporary files" block of a session's context. */
145
+ export function tempContext(rel) {
146
+ return (`## Temporary files\n` +
147
+ `Your scratch folder is ${rel}/ — TMPDIR (and KRAFTWERK_TEMP) point there, so \`mktemp\`, Python's tempfile, ` +
148
+ `os.tmpdir() and your own scratchpad land in it by themselves. Put everything that is not a result there: ` +
149
+ `intermediate output, downloads, extracted archives, build output, one-off scripts, a markdown written for a CLI ` +
150
+ `to read. It is private to this session, kept out of git, and removed when the session is deleted or after a week ` +
151
+ `unused — never store anything there you or the user want to keep. The files folders named above hold only what ` +
152
+ `the user keeps: what you produce for them, and the material they gave you. Never create temporary or ` +
153
+ `intermediate files there, and never in the system's temp dir.`);
154
+ }
package/dist/harness.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface AgentInvocation {
47
47
  clis?: string[];
48
48
  /** MCP servers available in this phase, keyed by server name. */
49
49
  mcpServers?: Record<string, McpServerConfig>;
50
+ /** Extra environment for the agent process (the run's temp folder as TMPDIR, see core/temp.ts); on top of the workspace's. */
51
+ env?: Record<string, string>;
50
52
  resume?: string;
51
53
  onToolUse?: (tool: string, target: string) => void;
52
54
  onText?: (text: string) => void;
@@ -83,7 +83,7 @@ async function open(agent, inv, resume) {
83
83
  };
84
84
  const { child, conn } = await connectAcp(agent, {
85
85
  cwd: inv.cwd,
86
- env: adapterEnv(agent, { model: inv.model, effort: inv.effort }),
86
+ env: { ...adapterEnv(agent, { model: inv.model, effort: inv.effort }), ...inv.env },
87
87
  client,
88
88
  clientName: "kraftwerk",
89
89
  onError: (err) => end(`could not start the ${agent} adapter: ${err.message}`),
@@ -38,6 +38,7 @@ function invokeClaude(inv) {
38
38
  const child = spawn("claude", args, {
39
39
  cwd: inv.cwd,
40
40
  stdio: ["pipe", "pipe", "pipe"],
41
+ env: { ...process.env, ...inv.env },
41
42
  });
42
43
  child.stdin.write(inv.prompt);
43
44
  child.stdin.end();
@@ -62,6 +62,7 @@ function invokeCodex(inv) {
62
62
  const child = spawn("codex", args, {
63
63
  cwd: inv.cwd,
64
64
  stdio: ["pipe", "pipe", "pipe"],
65
+ env: { ...process.env, ...inv.env },
65
66
  });
66
67
  child.stdin.write(prompt);
67
68
  child.stdin.end();
@@ -64,6 +64,7 @@ function invokePi(inv) {
64
64
  const child = spawn("pi", args, {
65
65
  cwd: inv.cwd,
66
66
  stdio: ["pipe", "pipe", "pipe"],
67
+ env: { ...process.env, ...inv.env },
67
68
  });
68
69
  child.stdin.write(inv.prompt);
69
70
  child.stdin.end();
package/dist/run.d.ts CHANGED
@@ -39,6 +39,13 @@ export declare class Run {
39
39
  private readonly verbose;
40
40
  private readonly maxGateRetries;
41
41
  constructor(options: RunOptions);
42
+ /**
43
+ * The run's scratch folder, <run dir>/temp, as TMPDIR for every phase
44
+ * (core/temp.ts): inside the run dir so a sandboxed run finds it in its
45
+ * mount, created when the first phase starts.
46
+ */
47
+ private temp?;
48
+ private tempEnv;
42
49
  trace(event: string, data?: Record<string, unknown>): Promise<void>;
43
50
  agentPhase(params: {
44
51
  name: string;