@netnodeag/kraftwerk 0.43.0 → 0.45.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 +68 -0
- package/dist/acp.d.ts +131 -1
- package/dist/acp.js +163 -2
- package/dist/harnesses/acp.js +9 -5
- package/dist/inspector/chat/acp.d.ts +48 -2
- package/dist/inspector/chat/acp.js +471 -47
- package/dist/inspector/chat/backend.d.ts +30 -3
- package/dist/inspector/chat/pi.js +4 -1
- package/dist/inspector/chat/sessions.d.ts +60 -2
- package/dist/inspector/chat/sessions.js +274 -16
- package/dist/inspector/chat/store.d.ts +12 -0
- package/dist/inspector/chat/store.js +46 -0
- package/dist/inspector/chat/types.d.ts +185 -2
- package/dist/inspector/runner.js +29 -4
- package/dist/inspector/runs.d.ts +5 -0
- package/dist/inspector/runs.js +56 -16
- package/dist/inspector/server.js +133 -7
- package/inspector/dist/assets/{dist-LuoAT8wo.js → dist-3XKl7K9N.js} +1 -1
- package/inspector/dist/assets/{dist-NNs7Vtl3.js → dist-BPzx-u5t.js} +1 -1
- package/inspector/dist/assets/dist-BnF1Rm2M.js +1 -0
- package/inspector/dist/assets/{dist-Rc-i9obk.js → dist-CGccQQRD.js} +1 -1
- package/inspector/dist/assets/{dist-C-Yu0i8M.js → dist-CIjxdHNW.js} +1 -1
- package/inspector/dist/assets/{dist-FHTVbzBi.js → dist-CdchVWU8.js} +1 -1
- package/inspector/dist/assets/{dist-2tkXRwU5.js → dist-DCZKo3cg.js} +1 -1
- package/inspector/dist/assets/{dist-CypKz49n.js → dist-DJnrih_T.js} +1 -1
- package/inspector/dist/assets/{dist-Ck7zPi0Z.js → dist-DWGd5Kj_.js} +1 -1
- package/inspector/dist/assets/{dist-BX5KQfrx.js → dist-DgaFP-8o.js} +1 -1
- package/inspector/dist/assets/{dist-BoDJE1PN.js → dist-bVw1IlxS.js} +1 -1
- package/inspector/dist/assets/{editor-BI5xRg8C.js → editor-DITzQiO1.js} +3 -3
- package/inspector/dist/assets/index-CS1G-A97.css +1 -0
- package/inspector/dist/assets/index-CiJ79dQy.js +219 -0
- package/inspector/dist/assets/roboto-latin-600-normal-CzqH9ZEY.woff2 +0 -0
- package/inspector/dist/assets/roboto-latin-600-normal-X4S5XP0J.woff +0 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +2 -2
- package/inspector/dist/assets/dist-Q3GFwcs6.js +0 -1
- package/inspector/dist/assets/index-BUp72TiG.js +0 -218
- 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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
}
|