@netnodeag/kraftwerk 0.43.0 → 0.44.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 (33) hide show
  1. package/README.md +48 -0
  2. package/dist/acp.d.ts +131 -1
  3. package/dist/acp.js +163 -2
  4. package/dist/harnesses/acp.js +9 -5
  5. package/dist/inspector/chat/acp.d.ts +48 -2
  6. package/dist/inspector/chat/acp.js +471 -47
  7. package/dist/inspector/chat/backend.d.ts +30 -3
  8. package/dist/inspector/chat/pi.js +4 -1
  9. package/dist/inspector/chat/sessions.d.ts +60 -2
  10. package/dist/inspector/chat/sessions.js +274 -16
  11. package/dist/inspector/chat/store.d.ts +12 -0
  12. package/dist/inspector/chat/store.js +46 -0
  13. package/dist/inspector/chat/types.d.ts +185 -2
  14. package/dist/inspector/server.js +116 -5
  15. package/inspector/dist/assets/{dist-2tkXRwU5.js → dist-BBzscIhY.js} +1 -1
  16. package/inspector/dist/assets/{dist-Ck7zPi0Z.js → dist-BDk9auN5.js} +1 -1
  17. package/inspector/dist/assets/dist-BktrTMXT.js +1 -0
  18. package/inspector/dist/assets/{dist-LuoAT8wo.js → dist-BprKOWfC.js} +1 -1
  19. package/inspector/dist/assets/{dist-BoDJE1PN.js → dist-CLig8JSu.js} +1 -1
  20. package/inspector/dist/assets/{dist-CypKz49n.js → dist-Cq-0-mqe.js} +1 -1
  21. package/inspector/dist/assets/{dist-Rc-i9obk.js → dist-DVe7MUnQ.js} +1 -1
  22. package/inspector/dist/assets/{dist-C-Yu0i8M.js → dist-DqfkVJaW.js} +1 -1
  23. package/inspector/dist/assets/{dist-NNs7Vtl3.js → dist-S-LgiFkD.js} +1 -1
  24. package/inspector/dist/assets/{dist-FHTVbzBi.js → dist-SBBqiARl.js} +1 -1
  25. package/inspector/dist/assets/{dist-BX5KQfrx.js → dist-wCqvkQEl.js} +1 -1
  26. package/inspector/dist/assets/{editor-BI5xRg8C.js → editor-D__mCVTd.js} +3 -3
  27. package/inspector/dist/assets/index-DPdYC76U.css +1 -0
  28. package/inspector/dist/assets/index-De7hHAxt.js +219 -0
  29. package/inspector/dist/index.html +2 -2
  30. package/package.json +2 -2
  31. package/inspector/dist/assets/dist-Q3GFwcs6.js +0 -1
  32. package/inspector/dist/assets/index-BUp72TiG.js +0 -218
  33. package/inspector/dist/assets/index-h5Vh1ZaF.css +0 -1
@@ -1,4 +1,4 @@
1
- import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
1
+ import type { Attachment, ChatAgentId, ChatMeta, ChatScope, ElicitationAnswer, StoredChatEvent } from "./types.js";
2
2
  import { type Channel } from "../channels.js";
3
3
  /** Server shutdown: no agent subprocess may outlive the inspector. */
4
4
  export declare function disposeAllBackends(): void;
@@ -21,6 +21,8 @@ export declare function createChat(opts: {
21
21
  scope: ChatScope;
22
22
  /** Preset title (e.g. routine runs); otherwise the first message names the chat. */
23
23
  title?: string;
24
+ /** Continue one of the agent's own sessions (from listAgentSessionsFor) instead of starting fresh. */
25
+ resume?: string;
24
26
  }): Promise<ChatMeta>;
25
27
  /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
26
28
  export declare function chatAwaitingApproval(id: string): boolean;
@@ -35,6 +37,7 @@ export declare function getChat(id: string): Promise<{
35
37
  } | null>;
36
38
  export declare function postMessage(id: string, text: string, opts?: {
37
39
  from?: string;
40
+ attachments?: Attachment[];
38
41
  }): Promise<{
39
42
  error?: string;
40
43
  }>;
@@ -62,6 +65,55 @@ export declare function setChatVibeable(id: string, slug: string | null): Promis
62
65
  status?: number;
63
66
  meta?: ChatMeta;
64
67
  }>;
68
+ /**
69
+ * Fork a chat: a new chat with the same transcript whose agent continues
70
+ * from a copy of this session (session/fork) — try something without
71
+ * losing the original thread. Needs a session to fork: an agent that keeps
72
+ * one (claude, codex — pi has no ACP session) that has answered at least
73
+ * once. The source's process is (re)spawned if needed; the fork's own
74
+ * process starts with its first message and resumes the forked id.
75
+ */
76
+ export declare function forkChat(id: string): Promise<{
77
+ error?: string;
78
+ status?: number;
79
+ meta?: ChatMeta;
80
+ }>;
81
+ /**
82
+ * Start over with the agent: drop the process and the stored session id so
83
+ * the next message opens a fresh session (the transcript stays, and goes
84
+ * back in as context). The way out of a session the harness reports as
85
+ * unusable — a "new_session" failure action.
86
+ */
87
+ export declare function resetSession(id: string): Promise<{
88
+ error?: string;
89
+ status?: number;
90
+ meta?: ChatMeta;
91
+ }>;
92
+ export declare function answerElicitation(id: string, requestId: string, answer: ElicitationAnswer): Promise<{
93
+ error?: string;
94
+ }>;
95
+ /**
96
+ * Steer: hand a message into the agent's running turn instead of waiting
97
+ * for it to end. Shown in the thread like any message. When no turn is
98
+ * running (or the agent cannot take steering) it goes the ordinary way.
99
+ */
100
+ export declare function steerChat(id: string, text: string, attachments?: Attachment[]): Promise<{
101
+ error?: string;
102
+ }>;
103
+ export declare function stopChatTask(id: string, taskId: string): Promise<{
104
+ error?: string;
105
+ }>;
106
+ /** Change a session setting (model, thinking depth, ...) on the chat's live agent. */
107
+ export declare function setChatConfig(id: string, configId: string, value: string | boolean): Promise<{
108
+ error?: string;
109
+ }>;
110
+ /** The agent's own sessions in the project root (Claude Code / Codex transcripts), to continue one as a chat. */
111
+ export declare function listAgentSessionsFor(agent: ChatAgentId): Promise<Array<{
112
+ sessionId: string;
113
+ title?: string;
114
+ updatedAt?: string;
115
+ cwd: string;
116
+ }>>;
65
117
  export declare function resolvePermission(id: string, requestId: string, optionId: string | null): Promise<{
66
118
  error?: string;
67
119
  }>;
@@ -69,7 +121,13 @@ export declare function resolvePermission(id: string, requestId: string, optionI
69
121
  export declare function deleteChat(id: string): Promise<{
70
122
  error?: string;
71
123
  }>;
72
- export declare function cancelChat(id: string): Promise<{
124
+ /**
125
+ * Interrupt the agent's turn — in a channel, one agent's (`agent` = its
126
+ * slug) or every agent's. Whatever the agent was waiting on from a human
127
+ * is dismissed, and an agent queued to run again when its turn ends is
128
+ * not woken after all.
129
+ */
130
+ export declare function cancelChat(id: string, agent?: string): Promise<{
73
131
  error?: string;
74
132
  }>;
75
133
  /**
@@ -8,12 +8,12 @@ import { listWorkflows } from "../workflows.js";
8
8
  import { getAgent, listAgents } from "../agents.js";
9
9
  import { listRepos } from "../repos.js";
10
10
  import { resolveVibeable, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
11
- import { startAcpBackend } from "./acp.js";
11
+ import { deleteAgentSession, listAgentSessions, startAcpBackend } from "./acp.js";
12
12
  import { startPiBackend } from "./pi.js";
13
13
  import { UNATTENDED_PERMISSION_TIMEOUT_MS, declineOption, unattendedTimeoutLabel } from "./permissions.js";
14
14
  import { chatHref, dismissKey, pushNotification } from "../notifications.js";
15
15
  import { getChannel, mentionTargets } from "../channels.js";
16
- import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
16
+ import { appendEvent, attachmentPath, copyChatFiles, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
17
17
  const MAIN = "main";
18
18
  const states = new Map();
19
19
  const newSeat = (key, harness) => ({
@@ -78,6 +78,14 @@ function dropSeat(seat) {
78
78
  seat.backend?.dispose();
79
79
  seat.backend = null;
80
80
  }
81
+ /** A turn failed: a resumed session that never took a prompt is gone for good — stop resuming it. */
82
+ function forgetFailedResume(state, seat) {
83
+ if (!seat.backend?.resumeFailed || !state.meta.sessions?.[seat.key])
84
+ return;
85
+ const { [seat.key]: _gone, ...rest } = state.meta.sessions;
86
+ state.meta.sessions = rest;
87
+ emit(state, { type: "error", message: "the previous session could not be continued — the next message starts a fresh one" }, seatAuthor(seat));
88
+ }
81
89
  /** Kill a chat's agent subprocess(es); the next message spawns fresh ones. */
82
90
  function dropBackend(state) {
83
91
  for (const seat of state.seats.values())
@@ -118,6 +126,8 @@ async function loadState(id) {
118
126
  subscribers: new Set(),
119
127
  seats: new Map(),
120
128
  pendingPermissions: new Map(),
129
+ pendingElicitations: new Map(),
130
+ pendingSeat: new Map(),
121
131
  writeChain: Promise.resolve(),
122
132
  };
123
133
  // Two racing loads: keep whichever registered first.
@@ -137,6 +147,7 @@ async function loadState(id) {
137
147
  */
138
148
  function closeInterruptedWork(state) {
139
149
  const pending = new Map();
150
+ const asked = new Map();
140
151
  const open = new Map();
141
152
  const key = (a) => (a?.kind === "agent" ? `agent:${a.slug}` : "main");
142
153
  for (const e of state.events) {
@@ -147,6 +158,12 @@ function closeInterruptedWork(state) {
147
158
  case "permission_resolved":
148
159
  pending.delete(e.requestId);
149
160
  break;
161
+ case "elicitation_request":
162
+ asked.set(e.requestId, e.from);
163
+ break;
164
+ case "elicitation_resolved":
165
+ asked.delete(e.requestId);
166
+ break;
150
167
  // Channels: a turn is bracketed by turn_start/turn_end per agent.
151
168
  // Ordinary chats never emit turn_start: the user's message opens the
152
169
  // turn, turn_end/error closes it.
@@ -167,12 +184,16 @@ function closeInterruptedWork(state) {
167
184
  emit(state, { type: "permission_resolved", requestId, optionId: null }, from);
168
185
  void dismissKey(`approval:${state.meta.id}:${requestId}`);
169
186
  }
187
+ for (const [requestId, from] of asked) {
188
+ emit(state, { type: "elicitation_resolved", requestId, action: "cancel" }, from);
189
+ void dismissKey(`question:${state.meta.id}:${requestId}`);
190
+ }
170
191
  for (const from of open.values()) {
171
192
  const who = from?.kind === "agent" ? `@${from.slug}` : "the agent";
172
193
  const how = state.meta.scope.kind === "channel" ? `mention ${who} again` : "send another message";
173
194
  emit(state, { type: "error", message: `${who} was interrupted by an inspector restart — ${how} to continue` }, from);
174
195
  }
175
- if (pending.size || open.size)
196
+ if (pending.size || asked.size || open.size)
176
197
  void writeMeta(state.meta).catch(() => { });
177
198
  }
178
199
  function emit(state, ev, from) {
@@ -541,6 +562,10 @@ async function backendTuning(meta, seat) {
541
562
  // Routine runs: never a harness mode that skips asking (see unattendedMode in permissions.ts).
542
563
  if (isUnattended(scope))
543
564
  tuning.unattended = true;
565
+ // A seat that had a session before (restart, idle reaper) continues it.
566
+ const previous = meta.sessions?.[seat.key];
567
+ if (previous && agent !== "pi")
568
+ tuning.resume = previous;
544
569
  // pi loads skills only when told: hand it every visible skill folder.
545
570
  if (agent === "pi") {
546
571
  const skills = await availableSkills(scope);
@@ -566,10 +591,12 @@ async function ensureBackend(state, seat) {
566
591
  const requestId = randomUUID();
567
592
  const notifyKey = `approval:${state.meta.id}:${requestId}`;
568
593
  let deadline;
594
+ state.pendingSeat.set(requestId, seat.key);
569
595
  state.pendingPermissions.set(requestId, (optionId) => {
570
596
  if (deadline)
571
597
  clearTimeout(deadline);
572
598
  state.pendingPermissions.delete(requestId);
599
+ state.pendingSeat.delete(requestId);
573
600
  emit(state, { type: "permission_resolved", requestId, optionId }, me);
574
601
  void dismissKey(notifyKey);
575
602
  resolve(optionId);
@@ -598,6 +625,48 @@ async function ensureBackend(state, seat) {
598
625
  }
599
626
  });
600
627
  },
628
+ askElicitation: (message, fields) => {
629
+ // The agent wants to know something before it goes on. Same contract
630
+ // as permissions: the question waits in the thread for a human; an
631
+ // unattended session gets a deadline after which the question is
632
+ // declined (the agent is told the user skipped it), never answered.
633
+ return new Promise((resolve) => {
634
+ const requestId = randomUUID();
635
+ const notifyKey = `question:${state.meta.id}:${requestId}`;
636
+ let deadline;
637
+ state.pendingSeat.set(requestId, seat.key);
638
+ state.pendingElicitations.set(requestId, (answer) => {
639
+ if (deadline)
640
+ clearTimeout(deadline);
641
+ state.pendingElicitations.delete(requestId);
642
+ state.pendingSeat.delete(requestId);
643
+ emit(state, { type: "elicitation_resolved", requestId, ...answer }, me);
644
+ void dismissKey(notifyKey);
645
+ resolve(answer);
646
+ });
647
+ emit(state, { type: "elicitation_request", requestId, message, fields }, me);
648
+ void pushNotification({
649
+ kind: "approval",
650
+ key: notifyKey,
651
+ title: `${chatLabel(state.meta)}${me?.kind === "agent" ? ` · @${me.slug}` : ""} has a question`,
652
+ body: message,
653
+ href: chatHref(state.meta),
654
+ });
655
+ if (isUnattended(state.meta.scope)) {
656
+ deadline = setTimeout(() => {
657
+ const pending = state.pendingElicitations.get(requestId);
658
+ if (!pending)
659
+ return;
660
+ emit(state, {
661
+ type: "error",
662
+ message: `nobody answered the agent's question within ${unattendedTimeoutLabel()} — skipped (unattended routine session)`,
663
+ });
664
+ pending({ action: "decline" });
665
+ }, UNATTENDED_PERMISSION_TIMEOUT_MS);
666
+ deadline.unref?.();
667
+ }
668
+ });
669
+ },
601
670
  };
602
671
  const agent = seat.harness;
603
672
  const cwd = await effectiveCwd(state.meta);
@@ -606,9 +675,14 @@ async function ensureBackend(state, seat) {
606
675
  agent === "pi"
607
676
  ? startPiBackend(cwd, hooks, tuning)
608
677
  : await startAcpBackend(agent, cwd, hooks, tuning);
609
- // Fresh process = fresh context window: re-send scope context with the
610
- // next prompt (matters for chats resumed after an inspector restart).
611
- seat.needsContext = true;
678
+ if (seat.backend.sessionId) {
679
+ state.meta.sessions = { ...state.meta.sessions, [seat.key]: seat.backend.sessionId };
680
+ void writeMeta(state.meta).catch(() => { });
681
+ }
682
+ // A resumed session still has the conversation (and the scope context)
683
+ // in its window. A fresh process is a fresh context window: re-send the
684
+ // scope context with the next prompt.
685
+ seat.needsContext = !seat.backend.resumed;
612
686
  return seat.backend;
613
687
  }
614
688
  /* ---------- public API (used by server.ts) ---------- */
@@ -635,6 +709,7 @@ export async function createChat(opts) {
635
709
  title: opts.title ?? "",
636
710
  cwd,
637
711
  scope: opts.scope,
712
+ ...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
638
713
  createdAt: now,
639
714
  updatedAt: now,
640
715
  };
@@ -645,6 +720,8 @@ export async function createChat(opts) {
645
720
  subscribers: new Set(),
646
721
  seats: new Map(),
647
722
  pendingPermissions: new Map(),
723
+ pendingElicitations: new Map(),
724
+ pendingSeat: new Map(),
648
725
  writeChain: Promise.resolve(),
649
726
  });
650
727
  return meta;
@@ -667,12 +744,15 @@ export async function getChat(id) {
667
744
  return null;
668
745
  return { meta: state.meta, events: state.events, busy: isBusy(state) };
669
746
  }
747
+ /** Attachments as the backend gets them: with their path on disk. */
748
+ const promptFiles = (id, attachments = []) => attachments.map((a) => ({ ...a, path: attachmentPath(id, a.name) }));
670
749
  export async function postMessage(id, text, opts = {}) {
671
750
  const state = await loadState(id);
672
751
  if (!state)
673
752
  return { error: "not found" };
753
+ const attachments = opts.attachments?.length ? opts.attachments : undefined;
674
754
  if (state.meta.scope.kind === "channel")
675
- return postChannelMessage(state, state.meta.scope.slug, text, opts.from);
755
+ return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments);
676
756
  const seat = mainSeat(state);
677
757
  if (seat.busy)
678
758
  return { error: "agent is still working — wait for the turn to finish" };
@@ -682,7 +762,7 @@ export async function postMessage(id, text, opts = {}) {
682
762
  state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
683
763
  void writeMeta(state.meta).catch(() => { });
684
764
  }
685
- const turnStart = emit(state, { type: "user_message", text }).seq;
765
+ const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }).seq;
686
766
  // The turn runs in the background; the HTTP request returns immediately
687
767
  // and the browser follows along on the SSE stream.
688
768
  void (async () => {
@@ -694,7 +774,7 @@ export async function postMessage(id, text, opts = {}) {
694
774
  // the thread keeps the short form the user typed.
695
775
  const body = await expandSkillInvocation(state.meta.scope, text);
696
776
  const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
697
- const stopReason = await backend.prompt(promptText);
777
+ const stopReason = await backend.prompt(promptText, promptFiles(id, attachments));
698
778
  emit(state, { type: "turn_end", stopReason });
699
779
  // Routine sessions are one-shot and unattended: release the agent
700
780
  // process as soon as the turn ends instead of waiting for the reaper,
@@ -724,6 +804,7 @@ export async function postMessage(id, text, opts = {}) {
724
804
  }
725
805
  // A failed turn may mean a dead subprocess; drop it so the next
726
806
  // message spawns a fresh agent (thread history stays on disk).
807
+ forgetFailedResume(state, seat);
727
808
  dropBackend(state);
728
809
  }
729
810
  finally {
@@ -740,12 +821,12 @@ export async function postMessage(id, text, opts = {}) {
740
821
  * parallel, never blocking the humans, who can keep posting. An agent that
741
822
  * is still busy is woken again when its turn ends, so nothing is lost.
742
823
  */
743
- async function postChannelMessage(state, slug, text, from) {
824
+ async function postChannelMessage(state, slug, text, from, attachments) {
744
825
  const channel = await getChannel(slug).catch(() => null);
745
826
  if (!channel)
746
827
  return { error: `channel "${slug}" has no definition under channels/` };
747
828
  const name = (from ?? "").replace(/\s+/g, " ").trim().slice(0, 40) || "you";
748
- emit(state, { type: "user_message", text }, { kind: "human", name });
829
+ emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }, { kind: "human", name });
749
830
  void writeMeta(state.meta).catch(() => { });
750
831
  for (const target of mentionTargets(text, channel))
751
832
  void wakeSeat(state, channel, target, 0);
@@ -787,7 +868,8 @@ function transcriptSince(state, afterSeq, beforeSeq, self) {
787
868
  continue;
788
869
  if (e.type === "user_message") {
789
870
  flush();
790
- lines.push(`[${e.from?.kind === "human" ? e.from.name : "human"}]: ${e.text}`);
871
+ const files = e.attachments?.length ? `\n(attached: ${e.attachments.map((a) => attachmentPath(state.meta.id, a.name)).join(", ")})` : "";
872
+ lines.push(`[${e.from?.kind === "human" ? e.from.name : "human"}]: ${e.text}${files}`);
791
873
  }
792
874
  else if (e.type === "text" && e.from?.kind === "agent" && e.from.slug !== self) {
793
875
  if (open && open.slug !== e.from.slug)
@@ -843,6 +925,7 @@ async function runSeatTurn(state, channel, seat, hops) {
843
925
  }
844
926
  catch (err) {
845
927
  emit(state, { type: "error", message: err.message }, me);
928
+ forgetFailedResume(state, seat);
846
929
  dropSeat(seat);
847
930
  }
848
931
  finally {
@@ -886,6 +969,10 @@ export async function convertChatToChannel(chatId, channel) {
886
969
  seat.needsContext = true;
887
970
  seat.seenSeq = state.events[state.events.length - 1]?.seq ?? 0;
888
971
  state.seats.set(scope.slug, seat);
972
+ if (state.meta.sessions?.[MAIN]) {
973
+ const { [MAIN]: session, ...rest } = state.meta.sessions;
974
+ state.meta.sessions = { ...rest, [scope.slug]: session };
975
+ }
889
976
  state.meta.scope = { kind: "channel", slug: channel.slug };
890
977
  state.meta.title = channel.name;
891
978
  state.meta.updatedAt = new Date().toISOString();
@@ -942,11 +1029,155 @@ export async function setChatVibeable(id, slug) {
942
1029
  }
943
1030
  state.meta.updatedAt = new Date().toISOString();
944
1031
  dropBackend(state);
1032
+ // The agent's session belongs to the old folder: start over there.
1033
+ delete state.meta.sessions;
1034
+ for (const seat of state.seats.values())
1035
+ seat.needsContext = true;
1036
+ await writeMeta(state.meta);
1037
+ return { meta: state.meta };
1038
+ }
1039
+ /**
1040
+ * Fork a chat: a new chat with the same transcript whose agent continues
1041
+ * from a copy of this session (session/fork) — try something without
1042
+ * losing the original thread. Needs a session to fork: an agent that keeps
1043
+ * one (claude, codex — pi has no ACP session) that has answered at least
1044
+ * once. The source's process is (re)spawned if needed; the fork's own
1045
+ * process starts with its first message and resumes the forked id.
1046
+ */
1047
+ export async function forkChat(id) {
1048
+ const state = await loadState(id);
1049
+ if (!state)
1050
+ return { error: "not found", status: 404 };
1051
+ const { meta } = state;
1052
+ if (meta.scope.kind === "channel")
1053
+ return { error: "a channel cannot be forked", status: 409 };
1054
+ if (meta.agent === "pi")
1055
+ return { error: "pi chats have no session to fork", status: 409 };
1056
+ if (meta.agent === "codex")
1057
+ return { error: "fork is not supported by codex", status: 409 };
1058
+ if (!meta.sessions?.[MAIN])
1059
+ return { error: "nothing to fork yet — the agent has not answered in this chat", status: 409 };
1060
+ if (isBusy(state))
1061
+ return { error: "agent is still working — wait for the turn to finish", status: 409 };
1062
+ const seat = mainSeat(state);
1063
+ let forked;
1064
+ try {
1065
+ const backend = await ensureBackend(state, seat);
1066
+ if (!backend.fork)
1067
+ return { error: `${meta.agent} cannot fork sessions`, status: 409 };
1068
+ forked = await backend.fork();
1069
+ }
1070
+ catch (err) {
1071
+ return { error: err.message, status: 409 };
1072
+ }
1073
+ const now = new Date().toISOString();
1074
+ const copy = {
1075
+ ...meta,
1076
+ id: newChatId(),
1077
+ title: meta.title ? `${meta.title} (fork)` : "",
1078
+ sessions: { [MAIN]: forked },
1079
+ createdAt: now,
1080
+ updatedAt: now,
1081
+ };
1082
+ await writeMeta(copy);
1083
+ await copyChatFiles(meta.id, copy.id);
1084
+ return { meta: copy };
1085
+ }
1086
+ /**
1087
+ * Start over with the agent: drop the process and the stored session id so
1088
+ * the next message opens a fresh session (the transcript stays, and goes
1089
+ * back in as context). The way out of a session the harness reports as
1090
+ * unusable — a "new_session" failure action.
1091
+ */
1092
+ export async function resetSession(id) {
1093
+ const state = await loadState(id);
1094
+ if (!state)
1095
+ return { error: "not found", status: 404 };
1096
+ if (isBusy(state))
1097
+ return { error: "agent is still working — wait for the turn to finish", status: 409 };
1098
+ dropBackend(state);
1099
+ delete state.meta.sessions;
945
1100
  for (const seat of state.seats.values())
946
1101
  seat.needsContext = true;
1102
+ state.meta.updatedAt = new Date().toISOString();
947
1103
  await writeMeta(state.meta);
1104
+ emit(state, { type: "error", message: "session reset — the next message starts a fresh one, with the conversation so far as context" });
948
1105
  return { meta: state.meta };
949
1106
  }
1107
+ export async function answerElicitation(id, requestId, answer) {
1108
+ const state = await loadState(id);
1109
+ if (!state)
1110
+ return { error: "not found" };
1111
+ const resolve = state.pendingElicitations.get(requestId);
1112
+ if (!resolve)
1113
+ return { error: "no such pending question" };
1114
+ resolve(answer);
1115
+ return {};
1116
+ }
1117
+ /**
1118
+ * Steer: hand a message into the agent's running turn instead of waiting
1119
+ * for it to end. Shown in the thread like any message. When no turn is
1120
+ * running (or the agent cannot take steering) it goes the ordinary way.
1121
+ */
1122
+ export async function steerChat(id, text, attachments) {
1123
+ const state = await loadState(id);
1124
+ if (!state)
1125
+ return { error: "not found" };
1126
+ if (state.meta.scope.kind === "channel")
1127
+ return { error: "channels take messages, not steering — @mention the agent" };
1128
+ const seat = mainSeat(state);
1129
+ const files = attachments?.length ? attachments : undefined;
1130
+ if (!seat.busy || !seat.backend?.steer)
1131
+ return postMessage(id, text, { attachments: files });
1132
+ try {
1133
+ const outcome = await seat.backend.steer(text, promptFiles(id, files));
1134
+ if (outcome === "promptRequired")
1135
+ return postMessage(id, text, { attachments: files });
1136
+ }
1137
+ catch (err) {
1138
+ return { error: err.message };
1139
+ }
1140
+ emit(state, { type: "user_message", text, steered: true, ...(files ? { attachments: files } : {}) });
1141
+ void writeMeta(state.meta).catch(() => { });
1142
+ return {};
1143
+ }
1144
+ export async function stopChatTask(id, taskId) {
1145
+ const state = await loadState(id);
1146
+ if (!state)
1147
+ return { error: "not found" };
1148
+ const backend = [...state.seats.values()].find((s) => s.backend?.stopTask)?.backend;
1149
+ if (!backend?.stopTask)
1150
+ return { error: "no running agent to stop the task on" };
1151
+ try {
1152
+ await backend.stopTask(taskId);
1153
+ return {};
1154
+ }
1155
+ catch (err) {
1156
+ return { error: err.message };
1157
+ }
1158
+ }
1159
+ /** Change a session setting (model, thinking depth, ...) on the chat's live agent. */
1160
+ export async function setChatConfig(id, configId, value) {
1161
+ const state = await loadState(id);
1162
+ if (!state)
1163
+ return { error: "not found" };
1164
+ const backend = mainSeat(state).backend;
1165
+ if (!backend?.setConfig)
1166
+ return { error: "the agent is not running — send a message first, the settings appear once it is up" };
1167
+ try {
1168
+ await backend.setConfig(configId, value);
1169
+ return {};
1170
+ }
1171
+ catch (err) {
1172
+ return { error: err.message };
1173
+ }
1174
+ }
1175
+ /** The agent's own sessions in the project root (Claude Code / Codex transcripts), to continue one as a chat. */
1176
+ export async function listAgentSessionsFor(agent) {
1177
+ if (agent === "pi")
1178
+ return [];
1179
+ return listAgentSessions(agent, getProjectRoot());
1180
+ }
950
1181
  export async function resolvePermission(id, requestId, optionId) {
951
1182
  const state = await loadState(id);
952
1183
  if (!state)
@@ -964,19 +1195,46 @@ export async function deleteChat(id) {
964
1195
  return { error: "not found" };
965
1196
  for (const resolve of state.pendingPermissions.values())
966
1197
  resolve(null);
1198
+ for (const resolve of state.pendingElicitations.values())
1199
+ resolve({ action: "cancel" });
967
1200
  dropBackend(state);
968
1201
  states.delete(id);
969
1202
  await fs.rm(safeChatDir(id), { recursive: true, force: true });
1203
+ // The agent's own transcripts go with the chat (best effort, in the background).
1204
+ const cwd = await effectiveCwd(state.meta);
1205
+ for (const [seatKey, sessionId] of Object.entries(state.meta.sessions ?? {})) {
1206
+ void (async () => {
1207
+ const harness = seatKey === MAIN ? state.meta.agent : (await getAgent(seatKey).catch(() => null))?.harness;
1208
+ if (harness && harness !== "pi")
1209
+ await deleteAgentSession(harness, cwd, sessionId);
1210
+ })().catch(() => { });
1211
+ }
970
1212
  return {};
971
1213
  }
972
- export async function cancelChat(id) {
1214
+ /**
1215
+ * Interrupt the agent's turn — in a channel, one agent's (`agent` = its
1216
+ * slug) or every agent's. Whatever the agent was waiting on from a human
1217
+ * is dismissed, and an agent queued to run again when its turn ends is
1218
+ * not woken after all.
1219
+ */
1220
+ export async function cancelChat(id, agent) {
973
1221
  const state = await loadState(id);
974
1222
  if (!state)
975
1223
  return { error: "not found" };
976
- for (const resolve of state.pendingPermissions.values())
977
- resolve(null);
978
- for (const seat of state.seats.values())
1224
+ const seats = agent ? [state.seats.get(agent)].filter((s) => !!s) : [...state.seats.values()];
1225
+ if (agent && seats.length === 0)
1226
+ return { error: `@${agent} is not running in this chat` };
1227
+ const mine = (requestId) => !agent || state.pendingSeat.get(requestId) === agent;
1228
+ for (const [requestId, resolve] of state.pendingPermissions)
1229
+ if (mine(requestId))
1230
+ resolve(null);
1231
+ for (const [requestId, resolve] of state.pendingElicitations)
1232
+ if (mine(requestId))
1233
+ resolve({ action: "cancel" });
1234
+ for (const seat of seats) {
1235
+ seat.wake = null;
979
1236
  seat.backend?.cancel();
1237
+ }
980
1238
  return {};
981
1239
  }
982
1240
  /**
@@ -3,6 +3,18 @@ export declare function safeChatDir(id: string): string;
3
3
  export declare function newChatId(): string;
4
4
  export declare function writeMeta(meta: ChatMeta): Promise<void>;
5
5
  export declare function readMeta(id: string): Promise<ChatMeta | null>;
6
+ /** Where a chat keeps the files dropped into it. */
7
+ export declare function attachmentsDir(id: string): string;
8
+ /** A stored attachment's path; rejects names that could leave the folder. */
9
+ export declare function attachmentPath(id: string, name: string): string;
10
+ /** Store one uploaded file under the chat; returns the (deduplicated, sanitized) name it got. */
11
+ export declare function saveAttachment(id: string, originalName: string, data: Buffer): Promise<string>;
12
+ /**
13
+ * Copy one chat's transcript and attachments into another (a fork): the
14
+ * events as they are, the files so the copy never points at a chat that
15
+ * may be deleted. The target's meta.json must already exist.
16
+ */
17
+ export declare function copyChatFiles(fromId: string, toId: string): Promise<void>;
6
18
  export declare function appendEvent(id: string, ev: StoredChatEvent): Promise<void>;
7
19
  export declare function readEvents(id: string): Promise<StoredChatEvent[]>;
8
20
  export declare function listChatMetas(): Promise<ChatMeta[]>;
@@ -44,6 +44,52 @@ function upgradeMeta(meta) {
44
44
  }
45
45
  return meta;
46
46
  }
47
+ /** Where a chat keeps the files dropped into it. */
48
+ export function attachmentsDir(id) {
49
+ return path.join(safeChatDir(id), "attachments");
50
+ }
51
+ const SAFE_NAME = /^[A-Za-z0-9._-]{1,120}$/;
52
+ /** A stored attachment's path; rejects names that could leave the folder. */
53
+ export function attachmentPath(id, name) {
54
+ if (!SAFE_NAME.test(name) || name.startsWith("."))
55
+ throw new Error("invalid attachment name");
56
+ return path.join(attachmentsDir(id), name);
57
+ }
58
+ /** Store one uploaded file under the chat; returns the (deduplicated, sanitized) name it got. */
59
+ export async function saveAttachment(id, originalName, data) {
60
+ const dir = attachmentsDir(id);
61
+ await fs.mkdir(dir, { recursive: true });
62
+ const base = (originalName || "file").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "").slice(0, 80) || "file";
63
+ const ext = path.extname(base);
64
+ const stem = base.slice(0, base.length - ext.length) || "file";
65
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
66
+ let name = `${stamp}-${stem}${ext}`;
67
+ for (let n = 2;; n++) {
68
+ try {
69
+ await fs.writeFile(path.join(dir, name), data, { flag: "wx" });
70
+ return name;
71
+ }
72
+ catch (err) {
73
+ if (err.code !== "EEXIST")
74
+ throw err;
75
+ name = `${stamp}-${stem}-${n}${ext}`;
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Copy one chat's transcript and attachments into another (a fork): the
81
+ * events as they are, the files so the copy never points at a chat that
82
+ * may be deleted. The target's meta.json must already exist.
83
+ */
84
+ export async function copyChatFiles(fromId, toId) {
85
+ const events = await readEvents(fromId);
86
+ if (events.length)
87
+ await fs.writeFile(path.join(safeChatDir(toId), "events.jsonl"), events.map((e) => JSON.stringify(e)).join("\n") + "\n");
88
+ await fs.cp(attachmentsDir(fromId), attachmentsDir(toId), { recursive: true }).catch((err) => {
89
+ if (err.code !== "ENOENT")
90
+ throw err;
91
+ });
92
+ }
47
93
  export async function appendEvent(id, ev) {
48
94
  await fs.appendFile(path.join(safeChatDir(id), "events.jsonl"), JSON.stringify(ev) + "\n");
49
95
  }