@netnodeag/kraftwerk 0.42.1 → 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.
- package/README.md +55 -6
- package/dist/acp.d.ts +131 -1
- package/dist/acp.js +163 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/harnesses/acp.js +9 -5
- package/dist/inspector/channels.d.ts +3 -0
- package/dist/inspector/channels.js +12 -1
- 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 +326 -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/git.js +8 -5
- package/dist/inspector/instances.d.ts +5 -7
- package/dist/inspector/instances.js +14 -10
- package/dist/inspector/search.d.ts +7 -5
- package/dist/inspector/search.js +5 -2
- package/dist/inspector/server.js +117 -6
- package/inspector/dist/assets/{dist-Cn-NK0yx.js → dist-BBzscIhY.js} +1 -1
- package/inspector/dist/assets/{dist-BnxCrUQZ.js → dist-BDk9auN5.js} +1 -1
- package/inspector/dist/assets/dist-BktrTMXT.js +1 -0
- package/inspector/dist/assets/{dist-DkSZQh-S.js → dist-BprKOWfC.js} +1 -1
- package/inspector/dist/assets/{dist-BpgTFX4N.js → dist-CLig8JSu.js} +1 -1
- package/inspector/dist/assets/{dist-OsHgcj_x.js → dist-Cq-0-mqe.js} +1 -1
- package/inspector/dist/assets/{dist-z049FsVf.js → dist-DVe7MUnQ.js} +1 -1
- package/inspector/dist/assets/{dist-DQbdG8Cf.js → dist-DqfkVJaW.js} +1 -1
- package/inspector/dist/assets/{dist-C8aIUi2c.js → dist-S-LgiFkD.js} +1 -1
- package/inspector/dist/assets/{dist-cZmo-9Se.js → dist-SBBqiARl.js} +1 -1
- package/inspector/dist/assets/{dist-BJxiusVh.js → dist-wCqvkQEl.js} +1 -1
- package/inspector/dist/assets/{editor-BQkS4m10.js → editor-D__mCVTd.js} +3 -3
- package/inspector/dist/assets/index-DPdYC76U.css +1 -0
- package/inspector/dist/assets/index-De7hHAxt.js +219 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +2 -2
- package/inspector/dist/assets/dist-C6Q5FWM0.js +0 -1
- package/inspector/dist/assets/index-DGlIITXq.js +0 -218
- package/inspector/dist/assets/index-DuZ9U4BZ.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,10 +126,75 @@ 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.
|
|
124
|
-
|
|
134
|
+
const registered = states.get(id) ?? (states.set(id, state), state);
|
|
135
|
+
if (registered === state)
|
|
136
|
+
closeInterruptedWork(state);
|
|
137
|
+
return registered;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A chat loaded from disk has no live process: whatever the transcript
|
|
141
|
+
* still shows as in flight died with the previous inspector (restart,
|
|
142
|
+
* relaunch after an update, crash). Left alone, the UI keeps rendering a
|
|
143
|
+
* permission card nobody can answer any more and a "working" lamp that
|
|
144
|
+
* never goes out. Close those out once, in the transcript itself, so
|
|
145
|
+
* every reader sees the same settled thread: dropped permission requests
|
|
146
|
+
* resolve to "dismissed", open turns end with an error that says why.
|
|
147
|
+
*/
|
|
148
|
+
function closeInterruptedWork(state) {
|
|
149
|
+
const pending = new Map();
|
|
150
|
+
const asked = new Map();
|
|
151
|
+
const open = new Map();
|
|
152
|
+
const key = (a) => (a?.kind === "agent" ? `agent:${a.slug}` : "main");
|
|
153
|
+
for (const e of state.events) {
|
|
154
|
+
switch (e.type) {
|
|
155
|
+
case "permission_request":
|
|
156
|
+
pending.set(e.requestId, e.from);
|
|
157
|
+
break;
|
|
158
|
+
case "permission_resolved":
|
|
159
|
+
pending.delete(e.requestId);
|
|
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;
|
|
167
|
+
// Channels: a turn is bracketed by turn_start/turn_end per agent.
|
|
168
|
+
// Ordinary chats never emit turn_start: the user's message opens the
|
|
169
|
+
// turn, turn_end/error closes it.
|
|
170
|
+
case "user_message":
|
|
171
|
+
if (state.meta.scope.kind !== "channel")
|
|
172
|
+
open.set("main", undefined);
|
|
173
|
+
break;
|
|
174
|
+
case "turn_start":
|
|
175
|
+
open.set(key(e.from), e.from);
|
|
176
|
+
break;
|
|
177
|
+
case "turn_end":
|
|
178
|
+
case "error":
|
|
179
|
+
open.delete(key(e.from));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const [requestId, from] of pending) {
|
|
184
|
+
emit(state, { type: "permission_resolved", requestId, optionId: null }, from);
|
|
185
|
+
void dismissKey(`approval:${state.meta.id}:${requestId}`);
|
|
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
|
+
}
|
|
191
|
+
for (const from of open.values()) {
|
|
192
|
+
const who = from?.kind === "agent" ? `@${from.slug}` : "the agent";
|
|
193
|
+
const how = state.meta.scope.kind === "channel" ? `mention ${who} again` : "send another message";
|
|
194
|
+
emit(state, { type: "error", message: `${who} was interrupted by an inspector restart — ${how} to continue` }, from);
|
|
195
|
+
}
|
|
196
|
+
if (pending.size || asked.size || open.size)
|
|
197
|
+
void writeMeta(state.meta).catch(() => { });
|
|
125
198
|
}
|
|
126
199
|
function emit(state, ev, from) {
|
|
127
200
|
const stored = {
|
|
@@ -489,6 +562,10 @@ async function backendTuning(meta, seat) {
|
|
|
489
562
|
// Routine runs: never a harness mode that skips asking (see unattendedMode in permissions.ts).
|
|
490
563
|
if (isUnattended(scope))
|
|
491
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;
|
|
492
569
|
// pi loads skills only when told: hand it every visible skill folder.
|
|
493
570
|
if (agent === "pi") {
|
|
494
571
|
const skills = await availableSkills(scope);
|
|
@@ -514,10 +591,12 @@ async function ensureBackend(state, seat) {
|
|
|
514
591
|
const requestId = randomUUID();
|
|
515
592
|
const notifyKey = `approval:${state.meta.id}:${requestId}`;
|
|
516
593
|
let deadline;
|
|
594
|
+
state.pendingSeat.set(requestId, seat.key);
|
|
517
595
|
state.pendingPermissions.set(requestId, (optionId) => {
|
|
518
596
|
if (deadline)
|
|
519
597
|
clearTimeout(deadline);
|
|
520
598
|
state.pendingPermissions.delete(requestId);
|
|
599
|
+
state.pendingSeat.delete(requestId);
|
|
521
600
|
emit(state, { type: "permission_resolved", requestId, optionId }, me);
|
|
522
601
|
void dismissKey(notifyKey);
|
|
523
602
|
resolve(optionId);
|
|
@@ -546,6 +625,48 @@ async function ensureBackend(state, seat) {
|
|
|
546
625
|
}
|
|
547
626
|
});
|
|
548
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
|
+
},
|
|
549
670
|
};
|
|
550
671
|
const agent = seat.harness;
|
|
551
672
|
const cwd = await effectiveCwd(state.meta);
|
|
@@ -554,9 +675,14 @@ async function ensureBackend(state, seat) {
|
|
|
554
675
|
agent === "pi"
|
|
555
676
|
? startPiBackend(cwd, hooks, tuning)
|
|
556
677
|
: await startAcpBackend(agent, cwd, hooks, tuning);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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;
|
|
560
686
|
return seat.backend;
|
|
561
687
|
}
|
|
562
688
|
/* ---------- public API (used by server.ts) ---------- */
|
|
@@ -583,6 +709,7 @@ export async function createChat(opts) {
|
|
|
583
709
|
title: opts.title ?? "",
|
|
584
710
|
cwd,
|
|
585
711
|
scope: opts.scope,
|
|
712
|
+
...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
|
|
586
713
|
createdAt: now,
|
|
587
714
|
updatedAt: now,
|
|
588
715
|
};
|
|
@@ -593,6 +720,8 @@ export async function createChat(opts) {
|
|
|
593
720
|
subscribers: new Set(),
|
|
594
721
|
seats: new Map(),
|
|
595
722
|
pendingPermissions: new Map(),
|
|
723
|
+
pendingElicitations: new Map(),
|
|
724
|
+
pendingSeat: new Map(),
|
|
596
725
|
writeChain: Promise.resolve(),
|
|
597
726
|
});
|
|
598
727
|
return meta;
|
|
@@ -615,12 +744,15 @@ export async function getChat(id) {
|
|
|
615
744
|
return null;
|
|
616
745
|
return { meta: state.meta, events: state.events, busy: isBusy(state) };
|
|
617
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) }));
|
|
618
749
|
export async function postMessage(id, text, opts = {}) {
|
|
619
750
|
const state = await loadState(id);
|
|
620
751
|
if (!state)
|
|
621
752
|
return { error: "not found" };
|
|
753
|
+
const attachments = opts.attachments?.length ? opts.attachments : undefined;
|
|
622
754
|
if (state.meta.scope.kind === "channel")
|
|
623
|
-
return postChannelMessage(state, state.meta.scope.slug, text, opts.from);
|
|
755
|
+
return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments);
|
|
624
756
|
const seat = mainSeat(state);
|
|
625
757
|
if (seat.busy)
|
|
626
758
|
return { error: "agent is still working — wait for the turn to finish" };
|
|
@@ -630,7 +762,7 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
630
762
|
state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
631
763
|
void writeMeta(state.meta).catch(() => { });
|
|
632
764
|
}
|
|
633
|
-
const turnStart = emit(state, { type: "user_message", text }).seq;
|
|
765
|
+
const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }).seq;
|
|
634
766
|
// The turn runs in the background; the HTTP request returns immediately
|
|
635
767
|
// and the browser follows along on the SSE stream.
|
|
636
768
|
void (async () => {
|
|
@@ -642,7 +774,7 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
642
774
|
// the thread keeps the short form the user typed.
|
|
643
775
|
const body = await expandSkillInvocation(state.meta.scope, text);
|
|
644
776
|
const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
|
|
645
|
-
const stopReason = await backend.prompt(promptText);
|
|
777
|
+
const stopReason = await backend.prompt(promptText, promptFiles(id, attachments));
|
|
646
778
|
emit(state, { type: "turn_end", stopReason });
|
|
647
779
|
// Routine sessions are one-shot and unattended: release the agent
|
|
648
780
|
// process as soon as the turn ends instead of waiting for the reaper,
|
|
@@ -672,6 +804,7 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
672
804
|
}
|
|
673
805
|
// A failed turn may mean a dead subprocess; drop it so the next
|
|
674
806
|
// message spawns a fresh agent (thread history stays on disk).
|
|
807
|
+
forgetFailedResume(state, seat);
|
|
675
808
|
dropBackend(state);
|
|
676
809
|
}
|
|
677
810
|
finally {
|
|
@@ -688,12 +821,12 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
688
821
|
* parallel, never blocking the humans, who can keep posting. An agent that
|
|
689
822
|
* is still busy is woken again when its turn ends, so nothing is lost.
|
|
690
823
|
*/
|
|
691
|
-
async function postChannelMessage(state, slug, text, from) {
|
|
824
|
+
async function postChannelMessage(state, slug, text, from, attachments) {
|
|
692
825
|
const channel = await getChannel(slug).catch(() => null);
|
|
693
826
|
if (!channel)
|
|
694
827
|
return { error: `channel "${slug}" has no definition under channels/` };
|
|
695
828
|
const name = (from ?? "").replace(/\s+/g, " ").trim().slice(0, 40) || "you";
|
|
696
|
-
emit(state, { type: "user_message", text }, { kind: "human", name });
|
|
829
|
+
emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }, { kind: "human", name });
|
|
697
830
|
void writeMeta(state.meta).catch(() => { });
|
|
698
831
|
for (const target of mentionTargets(text, channel))
|
|
699
832
|
void wakeSeat(state, channel, target, 0);
|
|
@@ -735,7 +868,8 @@ function transcriptSince(state, afterSeq, beforeSeq, self) {
|
|
|
735
868
|
continue;
|
|
736
869
|
if (e.type === "user_message") {
|
|
737
870
|
flush();
|
|
738
|
-
|
|
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}`);
|
|
739
873
|
}
|
|
740
874
|
else if (e.type === "text" && e.from?.kind === "agent" && e.from.slug !== self) {
|
|
741
875
|
if (open && open.slug !== e.from.slug)
|
|
@@ -791,6 +925,7 @@ async function runSeatTurn(state, channel, seat, hops) {
|
|
|
791
925
|
}
|
|
792
926
|
catch (err) {
|
|
793
927
|
emit(state, { type: "error", message: err.message }, me);
|
|
928
|
+
forgetFailedResume(state, seat);
|
|
794
929
|
dropSeat(seat);
|
|
795
930
|
}
|
|
796
931
|
finally {
|
|
@@ -834,6 +969,10 @@ export async function convertChatToChannel(chatId, channel) {
|
|
|
834
969
|
seat.needsContext = true;
|
|
835
970
|
seat.seenSeq = state.events[state.events.length - 1]?.seq ?? 0;
|
|
836
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
|
+
}
|
|
837
976
|
state.meta.scope = { kind: "channel", slug: channel.slug };
|
|
838
977
|
state.meta.title = channel.name;
|
|
839
978
|
state.meta.updatedAt = new Date().toISOString();
|
|
@@ -890,11 +1029,155 @@ export async function setChatVibeable(id, slug) {
|
|
|
890
1029
|
}
|
|
891
1030
|
state.meta.updatedAt = new Date().toISOString();
|
|
892
1031
|
dropBackend(state);
|
|
1032
|
+
// The agent's session belongs to the old folder: start over there.
|
|
1033
|
+
delete state.meta.sessions;
|
|
893
1034
|
for (const seat of state.seats.values())
|
|
894
1035
|
seat.needsContext = true;
|
|
895
1036
|
await writeMeta(state.meta);
|
|
896
1037
|
return { meta: state.meta };
|
|
897
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;
|
|
1100
|
+
for (const seat of state.seats.values())
|
|
1101
|
+
seat.needsContext = true;
|
|
1102
|
+
state.meta.updatedAt = new Date().toISOString();
|
|
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" });
|
|
1105
|
+
return { meta: state.meta };
|
|
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
|
+
}
|
|
898
1181
|
export async function resolvePermission(id, requestId, optionId) {
|
|
899
1182
|
const state = await loadState(id);
|
|
900
1183
|
if (!state)
|
|
@@ -912,19 +1195,46 @@ export async function deleteChat(id) {
|
|
|
912
1195
|
return { error: "not found" };
|
|
913
1196
|
for (const resolve of state.pendingPermissions.values())
|
|
914
1197
|
resolve(null);
|
|
1198
|
+
for (const resolve of state.pendingElicitations.values())
|
|
1199
|
+
resolve({ action: "cancel" });
|
|
915
1200
|
dropBackend(state);
|
|
916
1201
|
states.delete(id);
|
|
917
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
|
+
}
|
|
918
1212
|
return {};
|
|
919
1213
|
}
|
|
920
|
-
|
|
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) {
|
|
921
1221
|
const state = await loadState(id);
|
|
922
1222
|
if (!state)
|
|
923
1223
|
return { error: "not found" };
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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;
|
|
927
1236
|
seat.backend?.cancel();
|
|
1237
|
+
}
|
|
928
1238
|
return {};
|
|
929
1239
|
}
|
|
930
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
|
}
|