@netnodeag/kraftwerk 0.40.0 → 0.41.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 +43 -0
- package/dist/inspector/channels.d.ts +48 -0
- package/dist/inspector/channels.js +130 -0
- package/dist/inspector/chat/sessions.d.ts +17 -1
- package/dist/inspector/chat/sessions.js +280 -44
- package/dist/inspector/chat/types.d.ts +18 -0
- package/dist/inspector/notifications.js +2 -0
- package/dist/inspector/server.js +74 -2
- package/inspector/dist/assets/{dist-HuQTcyVK.js → dist-BO4gDbWB.js} +1 -1
- package/inspector/dist/assets/{dist-Bv3fOisq.js → dist-BiIrAs8U.js} +1 -1
- package/inspector/dist/assets/{dist-uQIhtGKj.js → dist-Bj7XitTo.js} +1 -1
- package/inspector/dist/assets/{dist-D9-MFS0x.js → dist-BvHfHbzl.js} +1 -1
- package/inspector/dist/assets/{dist-Dyv2EX0S.js → dist-CASrSR5E.js} +1 -1
- package/inspector/dist/assets/{dist-XL4xXfDo.js → dist-CoT4dTT9.js} +1 -1
- package/inspector/dist/assets/{dist-Cex4_N6v.js → dist-CqbReWDG.js} +1 -1
- package/inspector/dist/assets/{dist-DdbyO9w9.js → dist-DKds1beX.js} +1 -1
- package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
- package/inspector/dist/assets/{dist-AXonJXol.js → dist-g0pveAaF.js} +1 -1
- package/inspector/dist/assets/{dist-BJLTdJRF.js → dist-h7tJ3T-Z.js} +1 -1
- package/inspector/dist/assets/{editor--4qv8VP5.js → editor-BiyVJ7Wz.js} +3 -3
- package/inspector/dist/assets/{index-E1E7-_NW.css → index-CHI0eOQa.css} +1 -1
- package/inspector/dist/assets/{index-BSqc9eg2.js → index-lwVGUk9H.js} +17 -17
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-UTSke4yL.js +0 -1
|
@@ -12,8 +12,31 @@ import { 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
|
+
import { getChannel, mentionTargets } from "../channels.js";
|
|
15
16
|
import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
|
|
17
|
+
const MAIN = "main";
|
|
16
18
|
const states = new Map();
|
|
19
|
+
const newSeat = (key, harness) => ({
|
|
20
|
+
key,
|
|
21
|
+
harness,
|
|
22
|
+
backend: null,
|
|
23
|
+
busy: false,
|
|
24
|
+
needsContext: true,
|
|
25
|
+
seenSeq: 0,
|
|
26
|
+
wake: null,
|
|
27
|
+
});
|
|
28
|
+
/** The one seat of an ordinary (non-channel) chat. */
|
|
29
|
+
function mainSeat(state) {
|
|
30
|
+
let seat = state.seats.get(MAIN);
|
|
31
|
+
if (!seat) {
|
|
32
|
+
seat = newSeat(MAIN, state.meta.agent);
|
|
33
|
+
state.seats.set(MAIN, seat);
|
|
34
|
+
}
|
|
35
|
+
return seat;
|
|
36
|
+
}
|
|
37
|
+
const isBusy = (state) => [...state.seats.values()].some((s) => s.busy);
|
|
38
|
+
/** The event author for a seat: channel members sign their events, the lone agent of a chat does not. */
|
|
39
|
+
const seatAuthor = (seat) => (seat.key === MAIN ? undefined : { kind: "agent", slug: seat.key });
|
|
17
40
|
/** Routine-fired sessions: nobody is expected to be watching live. */
|
|
18
41
|
const isUnattended = (scope) => scope.kind === "agent" && !!scope.routine;
|
|
19
42
|
/** Diagnose handle for a failed routine session (only called for unattended agent scopes). */
|
|
@@ -25,6 +48,8 @@ function routineRef(meta) {
|
|
|
25
48
|
}
|
|
26
49
|
/** How the bell names a chat: its title ("⏰ Morning digest"), else the agent, else "chat". */
|
|
27
50
|
function chatLabel(meta) {
|
|
51
|
+
if (meta.scope.kind === "channel")
|
|
52
|
+
return `#${meta.scope.slug}`;
|
|
28
53
|
if (meta.title)
|
|
29
54
|
return meta.title;
|
|
30
55
|
return meta.scope.kind === "agent" ? meta.scope.slug : "chat";
|
|
@@ -49,10 +74,14 @@ function lastEventText(state, afterSeq, type) {
|
|
|
49
74
|
const paras = text.split(/\n{2,}/).filter((p) => p.trim());
|
|
50
75
|
return paras.slice(-2).join("\n\n");
|
|
51
76
|
}
|
|
52
|
-
|
|
77
|
+
function dropSeat(seat) {
|
|
78
|
+
seat.backend?.dispose();
|
|
79
|
+
seat.backend = null;
|
|
80
|
+
}
|
|
81
|
+
/** Kill a chat's agent subprocess(es); the next message spawns fresh ones. */
|
|
53
82
|
function dropBackend(state) {
|
|
54
|
-
state.
|
|
55
|
-
|
|
83
|
+
for (const seat of state.seats.values())
|
|
84
|
+
dropSeat(seat);
|
|
56
85
|
}
|
|
57
86
|
// Idle reaper: a finished chat must not keep its agent subprocess alive
|
|
58
87
|
// forever (one ACP adapter + one harness binary per chat adds up fast —
|
|
@@ -64,7 +93,8 @@ const IDLE_BACKEND_MS = 15 * 60_000;
|
|
|
64
93
|
const reaper = setInterval(() => {
|
|
65
94
|
const cutoff = Date.now() - IDLE_BACKEND_MS;
|
|
66
95
|
for (const state of states.values()) {
|
|
67
|
-
|
|
96
|
+
const live = [...state.seats.values()].some((s) => s.backend);
|
|
97
|
+
if (live && !isBusy(state) && Date.parse(state.meta.updatedAt) < cutoff) {
|
|
68
98
|
dropBackend(state);
|
|
69
99
|
}
|
|
70
100
|
}
|
|
@@ -86,20 +116,19 @@ async function loadState(id) {
|
|
|
86
116
|
meta,
|
|
87
117
|
events: await readEvents(id),
|
|
88
118
|
subscribers: new Set(),
|
|
89
|
-
|
|
90
|
-
busy: false,
|
|
119
|
+
seats: new Map(),
|
|
91
120
|
pendingPermissions: new Map(),
|
|
92
121
|
writeChain: Promise.resolve(),
|
|
93
|
-
needsContext: true,
|
|
94
122
|
};
|
|
95
123
|
// Two racing loads: keep whichever registered first.
|
|
96
124
|
return states.get(id) ?? (states.set(id, state), state);
|
|
97
125
|
}
|
|
98
|
-
function emit(state, ev) {
|
|
126
|
+
function emit(state, ev, from) {
|
|
99
127
|
const stored = {
|
|
100
128
|
...ev,
|
|
101
129
|
seq: (state.events[state.events.length - 1]?.seq ?? 0) + 1,
|
|
102
130
|
ts: new Date().toISOString(),
|
|
131
|
+
...(from ? { from } : {}),
|
|
103
132
|
};
|
|
104
133
|
state.events.push(stored);
|
|
105
134
|
state.meta.updatedAt = stored.ts;
|
|
@@ -393,25 +422,57 @@ const RENDERING_BLOCK = `## Chat rendering\n` +
|
|
|
393
422
|
`with , or link any artifact the same way. ` +
|
|
394
423
|
`Prefer these relative /api/... URLs over file paths when showing results: they render directly ` +
|
|
395
424
|
`in this chat and keep working wherever the inspector is reachable.`;
|
|
396
|
-
async function scopeContext(meta) {
|
|
397
|
-
|
|
425
|
+
async function scopeContext(meta, seat) {
|
|
426
|
+
// A channel seat is the member agent in its own right: persona, skills and
|
|
427
|
+
// repositories as in its direct sessions, plus the channel rules.
|
|
428
|
+
const scope = meta.scope.kind === "channel" ? { kind: "agent", slug: seat.key } : meta.scope;
|
|
429
|
+
const agent = seat.harness;
|
|
398
430
|
// The repositories block reads every clone from git, so it runs alongside
|
|
399
431
|
// the rest instead of adding its spawns to the first prompt's latency.
|
|
400
|
-
const [base, repos, vibe, skills] = await Promise.all([
|
|
432
|
+
const [base, repos, vibe, skills, channel] = await Promise.all([
|
|
401
433
|
baseScopeContext(scope, agent),
|
|
402
434
|
scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
|
|
403
435
|
meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
|
|
404
436
|
availableSkills(scope),
|
|
437
|
+
meta.scope.kind === "channel" ? channelContext(meta.scope.slug, seat.key) : Promise.resolve(""),
|
|
405
438
|
]);
|
|
406
|
-
return [base, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
439
|
+
return [base, channel, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
440
|
+
}
|
|
441
|
+
/** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
|
|
442
|
+
async function channelContext(slug, self) {
|
|
443
|
+
const channel = await getChannel(slug).catch(() => null);
|
|
444
|
+
if (!channel)
|
|
445
|
+
return `You sit in channel #${slug}, but its definition under channels/ is missing. Tell the humans.`;
|
|
446
|
+
const agents = await listAgents().catch(() => []);
|
|
447
|
+
const others = channel.members
|
|
448
|
+
.filter((m) => m !== self)
|
|
449
|
+
.map((m) => {
|
|
450
|
+
const a = agents.find((x) => x.slug === m);
|
|
451
|
+
return `- @${m}${a ? ` — ${a.emoji} ${a.name}${a.description ? `: ${a.description}` : ""}` : ""}`;
|
|
452
|
+
})
|
|
453
|
+
.join("\n");
|
|
454
|
+
return (`## Channel #${channel.slug} — ${channel.name}\n` +
|
|
455
|
+
(channel.purpose ? `Purpose: ${channel.purpose}\n` : "") +
|
|
456
|
+
`This is a group conversation, like a Slack channel: humans and several agents share one transcript. ` +
|
|
457
|
+
`You are @${self}. Other agents here:\n${others || "(none — you are the only agent)"}\n\n` +
|
|
458
|
+
`How it works: each time you are woken you receive the messages posted since your last turn, each prefixed ` +
|
|
459
|
+
`with its author ("[Lukas]:" is a human, "[@slug]:" an agent). Reply as yourself, to the channel — everyone reads it. ` +
|
|
460
|
+
`You are woken when a message @mentions you` +
|
|
461
|
+
(channel.responder === self ? `, and for every message that mentions nobody (you are the channel's responder)` : "") +
|
|
462
|
+
`. To hand a task to another agent, @mention them in your reply and say what you need; they are woken with your ` +
|
|
463
|
+
`message. Do not mention an agent just to be polite — every mention starts a turn, and handovers are limited to ` +
|
|
464
|
+
`${channel.maxHops} per human message. Do not repeat what others already said; add your part. Keep replies short ` +
|
|
465
|
+
`unless the task needs detail; a long result is better as a file in the project than as a wall of text.`);
|
|
407
466
|
}
|
|
408
467
|
/* ---------- backend lifecycle ---------- */
|
|
409
468
|
/** Agent sessions carry the agent's model/effort; resolved live so edits apply to new backends. */
|
|
410
|
-
async function backendTuning(meta) {
|
|
411
|
-
const { scope
|
|
469
|
+
async function backendTuning(meta, seat) {
|
|
470
|
+
const { scope } = meta;
|
|
471
|
+
const agent = seat.harness;
|
|
412
472
|
const tuning = {};
|
|
413
|
-
|
|
414
|
-
|
|
473
|
+
const agentSlug = scope.kind === "agent" ? scope.slug : scope.kind === "channel" ? seat.key : undefined;
|
|
474
|
+
if (agentSlug) {
|
|
475
|
+
const def = await getAgent(agentSlug).catch(() => null);
|
|
415
476
|
if (def?.model)
|
|
416
477
|
tuning.model = def.model;
|
|
417
478
|
if (def?.effort)
|
|
@@ -436,11 +497,12 @@ async function backendTuning(meta) {
|
|
|
436
497
|
}
|
|
437
498
|
return tuning;
|
|
438
499
|
}
|
|
439
|
-
async function ensureBackend(state) {
|
|
440
|
-
if (
|
|
441
|
-
return
|
|
500
|
+
async function ensureBackend(state, seat) {
|
|
501
|
+
if (seat.backend)
|
|
502
|
+
return seat.backend;
|
|
503
|
+
const me = seatAuthor(seat);
|
|
442
504
|
const hooks = {
|
|
443
|
-
emit: (ev) => void emit(state, ev),
|
|
505
|
+
emit: (ev) => void emit(state, ev, me),
|
|
444
506
|
askPermission: (title, options) => {
|
|
445
507
|
// The harness already judged this call worth asking about, so the
|
|
446
508
|
// question always goes to a human — routine sessions included. The
|
|
@@ -456,16 +518,16 @@ async function ensureBackend(state) {
|
|
|
456
518
|
if (deadline)
|
|
457
519
|
clearTimeout(deadline);
|
|
458
520
|
state.pendingPermissions.delete(requestId);
|
|
459
|
-
emit(state, { type: "permission_resolved", requestId, optionId });
|
|
521
|
+
emit(state, { type: "permission_resolved", requestId, optionId }, me);
|
|
460
522
|
void dismissKey(notifyKey);
|
|
461
523
|
resolve(optionId);
|
|
462
524
|
});
|
|
463
|
-
emit(state, { type: "permission_request", requestId, title, options });
|
|
525
|
+
emit(state, { type: "permission_request", requestId, title, options }, me);
|
|
464
526
|
// The bell: a question is waiting. Answered -> the item goes away again.
|
|
465
527
|
void pushNotification({
|
|
466
528
|
kind: "approval",
|
|
467
529
|
key: notifyKey,
|
|
468
|
-
title: `${chatLabel(state.meta)} needs approval`,
|
|
530
|
+
title: `${chatLabel(state.meta)}${me?.kind === "agent" ? ` · @${me.slug}` : ""} needs approval`,
|
|
469
531
|
body: title,
|
|
470
532
|
href: chatHref(state.meta),
|
|
471
533
|
});
|
|
@@ -485,17 +547,17 @@ async function ensureBackend(state) {
|
|
|
485
547
|
});
|
|
486
548
|
},
|
|
487
549
|
};
|
|
488
|
-
const
|
|
550
|
+
const agent = seat.harness;
|
|
489
551
|
const cwd = await effectiveCwd(state.meta);
|
|
490
|
-
const tuning = await backendTuning(state.meta);
|
|
491
|
-
|
|
552
|
+
const tuning = await backendTuning(state.meta, seat);
|
|
553
|
+
seat.backend =
|
|
492
554
|
agent === "pi"
|
|
493
555
|
? startPiBackend(cwd, hooks, tuning)
|
|
494
556
|
: await startAcpBackend(agent, cwd, hooks, tuning);
|
|
495
557
|
// Fresh process = fresh context window: re-send scope context with the
|
|
496
558
|
// next prompt (matters for chats resumed after an inspector restart).
|
|
497
|
-
|
|
498
|
-
return
|
|
559
|
+
seat.needsContext = true;
|
|
560
|
+
return seat.backend;
|
|
499
561
|
}
|
|
500
562
|
/* ---------- public API (used by server.ts) ---------- */
|
|
501
563
|
/** Where a chat's agent runs when no vibeable is open: the run folder for run chats, else the project root. */
|
|
@@ -529,11 +591,9 @@ export async function createChat(opts) {
|
|
|
529
591
|
meta,
|
|
530
592
|
events: [],
|
|
531
593
|
subscribers: new Set(),
|
|
532
|
-
|
|
533
|
-
busy: false,
|
|
594
|
+
seats: new Map(),
|
|
534
595
|
pendingPermissions: new Map(),
|
|
535
596
|
writeChain: Promise.resolve(),
|
|
536
|
-
needsContext: true,
|
|
537
597
|
});
|
|
538
598
|
return meta;
|
|
539
599
|
}
|
|
@@ -545,7 +605,7 @@ export async function listChats() {
|
|
|
545
605
|
const metas = await listChatMetas();
|
|
546
606
|
return metas.map((m) => ({
|
|
547
607
|
...(states.get(m.id)?.meta ?? m),
|
|
548
|
-
busy: states.get(m.id)
|
|
608
|
+
busy: states.has(m.id) ? isBusy(states.get(m.id)) : false,
|
|
549
609
|
awaitingApproval: chatAwaitingApproval(m.id),
|
|
550
610
|
}));
|
|
551
611
|
}
|
|
@@ -553,15 +613,18 @@ export async function getChat(id) {
|
|
|
553
613
|
const state = await loadState(id);
|
|
554
614
|
if (!state)
|
|
555
615
|
return null;
|
|
556
|
-
return { meta: state.meta, events: state.events, busy: state
|
|
616
|
+
return { meta: state.meta, events: state.events, busy: isBusy(state) };
|
|
557
617
|
}
|
|
558
|
-
export async function postMessage(id, text) {
|
|
618
|
+
export async function postMessage(id, text, opts = {}) {
|
|
559
619
|
const state = await loadState(id);
|
|
560
620
|
if (!state)
|
|
561
621
|
return { error: "not found" };
|
|
562
|
-
if (state.
|
|
622
|
+
if (state.meta.scope.kind === "channel")
|
|
623
|
+
return postChannelMessage(state, state.meta.scope.slug, text, opts.from);
|
|
624
|
+
const seat = mainSeat(state);
|
|
625
|
+
if (seat.busy)
|
|
563
626
|
return { error: "agent is still working — wait for the turn to finish" };
|
|
564
|
-
|
|
627
|
+
seat.busy = true;
|
|
565
628
|
const isFirst = !state.events.some((e) => e.type === "user_message");
|
|
566
629
|
if (isFirst && !state.meta.title) {
|
|
567
630
|
state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
@@ -572,9 +635,9 @@ export async function postMessage(id, text) {
|
|
|
572
635
|
// and the browser follows along on the SSE stream.
|
|
573
636
|
void (async () => {
|
|
574
637
|
try {
|
|
575
|
-
const backend = await ensureBackend(state);
|
|
576
|
-
const context =
|
|
577
|
-
|
|
638
|
+
const backend = await ensureBackend(state, seat);
|
|
639
|
+
const context = seat.needsContext ? await scopeContext(state.meta, seat) : "";
|
|
640
|
+
seat.needsContext = false;
|
|
578
641
|
// "/<skill> args" expands to the skill's instructions for the agent;
|
|
579
642
|
// the thread keeps the short form the user typed.
|
|
580
643
|
const body = await expandSkillInvocation(state.meta.scope, text);
|
|
@@ -612,12 +675,183 @@ export async function postMessage(id, text) {
|
|
|
612
675
|
dropBackend(state);
|
|
613
676
|
}
|
|
614
677
|
finally {
|
|
615
|
-
|
|
678
|
+
seat.busy = false;
|
|
616
679
|
void writeMeta(state.meta).catch(() => { });
|
|
617
680
|
}
|
|
618
681
|
})();
|
|
619
682
|
return {};
|
|
620
683
|
}
|
|
684
|
+
/* ---------- channels ---------- */
|
|
685
|
+
/**
|
|
686
|
+
* A human posts to a channel: the message is signed with their name and the
|
|
687
|
+
* agents it wakes (mentions, else the responder) each run a turn — in
|
|
688
|
+
* parallel, never blocking the humans, who can keep posting. An agent that
|
|
689
|
+
* is still busy is woken again when its turn ends, so nothing is lost.
|
|
690
|
+
*/
|
|
691
|
+
async function postChannelMessage(state, slug, text, from) {
|
|
692
|
+
const channel = await getChannel(slug).catch(() => null);
|
|
693
|
+
if (!channel)
|
|
694
|
+
return { error: `channel "${slug}" has no definition under channels/` };
|
|
695
|
+
const name = (from ?? "").replace(/\s+/g, " ").trim().slice(0, 40) || "you";
|
|
696
|
+
emit(state, { type: "user_message", text }, { kind: "human", name });
|
|
697
|
+
void writeMeta(state.meta).catch(() => { });
|
|
698
|
+
for (const target of mentionTargets(text, channel))
|
|
699
|
+
void wakeSeat(state, channel, target, 0);
|
|
700
|
+
return {};
|
|
701
|
+
}
|
|
702
|
+
/** Wake a member agent: run a turn now, or mark it to run again when its current turn ends. */
|
|
703
|
+
async function wakeSeat(state, channel, slug, hops) {
|
|
704
|
+
const def = await getAgent(slug).catch(() => null);
|
|
705
|
+
if (!def) {
|
|
706
|
+
emit(state, { type: "error", message: `@${slug} is in the channel but has no agent definition under agents/` });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
let seat = state.seats.get(slug);
|
|
710
|
+
if (!seat) {
|
|
711
|
+
seat = newSeat(slug, def.harness);
|
|
712
|
+
state.seats.set(slug, seat);
|
|
713
|
+
}
|
|
714
|
+
if (seat.busy) {
|
|
715
|
+
seat.wake = { hops: Math.min(hops, seat.wake?.hops ?? hops) };
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
await runSeatTurn(state, channel, seat, hops);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The transcript a seat has not seen yet, as the agent reads it: humans by
|
|
722
|
+
* name, other agents by @slug (their streamed text merged per turn), own
|
|
723
|
+
* messages and machinery (tools, permissions, thoughts) left out.
|
|
724
|
+
*/
|
|
725
|
+
function transcriptSince(state, afterSeq, beforeSeq, self) {
|
|
726
|
+
const lines = [];
|
|
727
|
+
let open = null;
|
|
728
|
+
const flush = () => {
|
|
729
|
+
if (open && open.text.trim())
|
|
730
|
+
lines.push(`[@${open.slug}]: ${open.text.trim()}`);
|
|
731
|
+
open = null;
|
|
732
|
+
};
|
|
733
|
+
for (const e of state.events) {
|
|
734
|
+
if (e.seq <= afterSeq || e.seq >= beforeSeq)
|
|
735
|
+
continue;
|
|
736
|
+
if (e.type === "user_message") {
|
|
737
|
+
flush();
|
|
738
|
+
lines.push(`[${e.from?.kind === "human" ? e.from.name : "human"}]: ${e.text}`);
|
|
739
|
+
}
|
|
740
|
+
else if (e.type === "text" && e.from?.kind === "agent" && e.from.slug !== self) {
|
|
741
|
+
if (open && open.slug !== e.from.slug)
|
|
742
|
+
flush();
|
|
743
|
+
open = open ?? { slug: e.from.slug, text: "" };
|
|
744
|
+
open.text += e.text;
|
|
745
|
+
}
|
|
746
|
+
else if (e.type === "turn_end" && open && e.from?.kind === "agent" && e.from.slug === open.slug) {
|
|
747
|
+
flush();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
flush();
|
|
751
|
+
return lines.join("\n\n");
|
|
752
|
+
}
|
|
753
|
+
/** What one agent said during a turn (its streamed text after `afterSeq`). */
|
|
754
|
+
function seatTextSince(state, afterSeq, slug) {
|
|
755
|
+
return state.events
|
|
756
|
+
.filter((e) => e.seq > afterSeq && e.type === "text" && e.from?.kind === "agent" && e.from.slug === slug)
|
|
757
|
+
.map((e) => e.text)
|
|
758
|
+
.join("");
|
|
759
|
+
}
|
|
760
|
+
async function runSeatTurn(state, channel, seat, hops) {
|
|
761
|
+
seat.busy = true;
|
|
762
|
+
const me = { kind: "agent", slug: seat.key };
|
|
763
|
+
const start = emit(state, { type: "turn_start" }, me).seq;
|
|
764
|
+
try {
|
|
765
|
+
const backend = await ensureBackend(state, seat);
|
|
766
|
+
const context = seat.needsContext ? await scopeContext(state.meta, seat) : "";
|
|
767
|
+
seat.needsContext = false;
|
|
768
|
+
const delta = transcriptSince(state, seat.seenSeq, start, seat.key);
|
|
769
|
+
seat.seenSeq = start;
|
|
770
|
+
if (!delta && !context) {
|
|
771
|
+
emit(state, { type: "turn_end", stopReason: "nothing_new" }, me);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const prompt = (context ? `<context>\n${context}\n</context>\n\n` : "") +
|
|
775
|
+
(delta ? `New messages in #${channel.slug}:\n\n${delta}` : `You joined #${channel.slug}. Nothing addressed to you yet — reply with one short line saying you are here.`);
|
|
776
|
+
const stopReason = await backend.prompt(prompt);
|
|
777
|
+
emit(state, { type: "turn_end", stopReason }, me);
|
|
778
|
+
// Handover: agents this one @mentioned get its message, within the hop budget.
|
|
779
|
+
const said = seatTextSince(state, start, seat.key);
|
|
780
|
+
const next = mentionTargets(said, channel, seat.key);
|
|
781
|
+
if (next.length > 0 && hops >= channel.maxHops) {
|
|
782
|
+
emit(state, {
|
|
783
|
+
type: "error",
|
|
784
|
+
message: `handover limit reached (${channel.maxHops} per human message) — @mention ${next.map((n) => `@${n}`).join(", ")} yourself to continue`,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
for (const target of next)
|
|
789
|
+
void wakeSeat(state, channel, target, hops + 1);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
emit(state, { type: "error", message: err.message }, me);
|
|
794
|
+
dropSeat(seat);
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
seat.busy = false;
|
|
798
|
+
void writeMeta(state.meta).catch(() => { });
|
|
799
|
+
if (seat.wake) {
|
|
800
|
+
const { hops: again } = seat.wake;
|
|
801
|
+
seat.wake = null;
|
|
802
|
+
void runSeatTurn(state, channel, seat, again);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/** The chat that holds a channel's transcript; created on first use. */
|
|
807
|
+
export async function ensureChannelChat(channel) {
|
|
808
|
+
const metas = await listChatMetas();
|
|
809
|
+
const existing = metas.find((m) => m.scope.kind === "channel" && m.scope.slug === channel.slug);
|
|
810
|
+
if (existing)
|
|
811
|
+
return states.get(existing.id)?.meta ?? existing;
|
|
812
|
+
return createChat({ agent: "claude", scope: { kind: "channel", slug: channel.slug }, title: channel.name });
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* "Add a coworker" to an agent session: the chat becomes the channel's
|
|
816
|
+
* transcript. The agent keeps its process and memory — it has seen every
|
|
817
|
+
* message so far — and learns the channel rules with its next prompt.
|
|
818
|
+
*/
|
|
819
|
+
export async function convertChatToChannel(chatId, channel) {
|
|
820
|
+
const state = await loadState(chatId);
|
|
821
|
+
if (!state)
|
|
822
|
+
return { error: "chat not found" };
|
|
823
|
+
const scope = state.meta.scope;
|
|
824
|
+
if (scope.kind !== "agent")
|
|
825
|
+
return { error: "only an agent session can become a channel" };
|
|
826
|
+
if (!channel.members.includes(scope.slug))
|
|
827
|
+
return { error: `the channel must include @${scope.slug}` };
|
|
828
|
+
if (isBusy(state))
|
|
829
|
+
return { error: "agent is still working — wait for the turn to finish" };
|
|
830
|
+
const main = state.seats.get(MAIN);
|
|
831
|
+
state.seats.delete(MAIN);
|
|
832
|
+
const seat = main ?? newSeat(scope.slug, state.meta.agent);
|
|
833
|
+
seat.key = scope.slug;
|
|
834
|
+
seat.needsContext = true;
|
|
835
|
+
seat.seenSeq = state.events[state.events.length - 1]?.seq ?? 0;
|
|
836
|
+
state.seats.set(scope.slug, seat);
|
|
837
|
+
state.meta.scope = { kind: "channel", slug: channel.slug };
|
|
838
|
+
state.meta.title = channel.name;
|
|
839
|
+
state.meta.updatedAt = new Date().toISOString();
|
|
840
|
+
await writeMeta(state.meta);
|
|
841
|
+
return { meta: state.meta };
|
|
842
|
+
}
|
|
843
|
+
/** Members left the channel: their processes go, the transcript stays. */
|
|
844
|
+
export async function dropChannelSeats(chatId, keep) {
|
|
845
|
+
const state = states.get(chatId);
|
|
846
|
+
if (!state)
|
|
847
|
+
return;
|
|
848
|
+
for (const [key, seat] of state.seats) {
|
|
849
|
+
if (!keep.includes(key)) {
|
|
850
|
+
dropSeat(seat);
|
|
851
|
+
state.seats.delete(key);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
621
855
|
/**
|
|
622
856
|
* Open a vibeable in the chat (or close it with null). The agent's cwd
|
|
623
857
|
* becomes the app folder, so the running subprocess is dropped and the next
|
|
@@ -629,7 +863,7 @@ export async function setChatVibeable(id, slug) {
|
|
|
629
863
|
if (!state)
|
|
630
864
|
return { error: "not found", status: 404 };
|
|
631
865
|
const busyError = { error: "agent is still working — wait for the turn to finish", status: 409 };
|
|
632
|
-
if (state
|
|
866
|
+
if (isBusy(state))
|
|
633
867
|
return busyError;
|
|
634
868
|
let dir = null;
|
|
635
869
|
if (slug) {
|
|
@@ -644,7 +878,7 @@ export async function setChatVibeable(id, slug) {
|
|
|
644
878
|
// A message may have started a turn during the await above; its backend
|
|
645
879
|
// was spawned in the old cwd, so refuse rather than relabel it. From here
|
|
646
880
|
// on nothing yields until cwd, vibeable and backend agree.
|
|
647
|
-
if (state
|
|
881
|
+
if (isBusy(state))
|
|
648
882
|
return busyError;
|
|
649
883
|
if (slug && dir) {
|
|
650
884
|
state.meta.vibeable = slug;
|
|
@@ -656,7 +890,8 @@ export async function setChatVibeable(id, slug) {
|
|
|
656
890
|
}
|
|
657
891
|
state.meta.updatedAt = new Date().toISOString();
|
|
658
892
|
dropBackend(state);
|
|
659
|
-
state.
|
|
893
|
+
for (const seat of state.seats.values())
|
|
894
|
+
seat.needsContext = true;
|
|
660
895
|
await writeMeta(state.meta);
|
|
661
896
|
return { meta: state.meta };
|
|
662
897
|
}
|
|
@@ -688,7 +923,8 @@ export async function cancelChat(id) {
|
|
|
688
923
|
return { error: "not found" };
|
|
689
924
|
for (const resolve of state.pendingPermissions.values())
|
|
690
925
|
resolve(null);
|
|
691
|
-
state.
|
|
926
|
+
for (const seat of state.seats.values())
|
|
927
|
+
seat.backend?.cancel();
|
|
692
928
|
return {};
|
|
693
929
|
}
|
|
694
930
|
/**
|
|
@@ -20,6 +20,19 @@ export type ChatScope = {
|
|
|
20
20
|
kind: "agent";
|
|
21
21
|
slug: string;
|
|
22
22
|
routine?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A channel: several agents (and humans) share one transcript; see channels.ts. */
|
|
25
|
+
| {
|
|
26
|
+
kind: "channel";
|
|
27
|
+
slug: string;
|
|
28
|
+
};
|
|
29
|
+
/** Who produced an event. Absent on single-agent chats (the one human, the one agent). */
|
|
30
|
+
export type Author = {
|
|
31
|
+
kind: "human";
|
|
32
|
+
name: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "agent";
|
|
35
|
+
slug: string;
|
|
23
36
|
};
|
|
24
37
|
export interface ChatMeta {
|
|
25
38
|
id: string;
|
|
@@ -66,6 +79,10 @@ export type ChatEvent = {
|
|
|
66
79
|
type: "permission_resolved";
|
|
67
80
|
requestId: string;
|
|
68
81
|
optionId: string | null;
|
|
82
|
+
}
|
|
83
|
+
/** Channels: an agent starts working on the messages it was shown (pairs with turn_end by the same author). */
|
|
84
|
+
| {
|
|
85
|
+
type: "turn_start";
|
|
69
86
|
} | {
|
|
70
87
|
type: "turn_end";
|
|
71
88
|
stopReason: string;
|
|
@@ -76,4 +93,5 @@ export type ChatEvent = {
|
|
|
76
93
|
export type StoredChatEvent = ChatEvent & {
|
|
77
94
|
seq: number;
|
|
78
95
|
ts: string;
|
|
96
|
+
from?: Author;
|
|
79
97
|
};
|
|
@@ -38,6 +38,8 @@ export function clipBody(text) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Inspector route for a chat — mirrors chatHref in the UI. */
|
|
40
40
|
export function chatHref(meta) {
|
|
41
|
+
if (meta.scope.kind === "channel")
|
|
42
|
+
return `/channels/${meta.scope.slug}`;
|
|
41
43
|
return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
|
|
42
44
|
}
|
|
43
45
|
export async function pushNotification(input) {
|
package/dist/inspector/server.js
CHANGED
|
@@ -8,7 +8,8 @@ import { listWorkflows, getWorkflow } from "./workflows.js";
|
|
|
8
8
|
import { dockerStatus, triggerRun, stopRun } from "./runner.js";
|
|
9
9
|
import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
|
|
10
10
|
import { startDiagnosis } from "./diagnose.js";
|
|
11
|
-
import {
|
|
11
|
+
import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
|
|
12
|
+
import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
|
|
12
13
|
import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
13
14
|
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
14
15
|
import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
|
|
@@ -190,6 +191,76 @@ async function handleApi(req, res, url) {
|
|
|
190
191
|
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
|
|
191
192
|
return json(res, { error: "cross-origin request refused" }, 403);
|
|
192
193
|
}
|
|
194
|
+
// Channels — shared transcripts with several agents (channels/<slug>/channel.yml + one chat).
|
|
195
|
+
// GET /api/channels | POST /api/channels {name, purpose?, members, responder?}
|
|
196
|
+
// POST /api/channels/from-chat {chatId, name, members, ...} — an agent session becomes a channel
|
|
197
|
+
// GET/PUT/DELETE /api/channels/:slug
|
|
198
|
+
if (seg.length >= 2 && seg[1] === "channels") {
|
|
199
|
+
const view = async (c) => {
|
|
200
|
+
const meta = await ensureChannelChat(c);
|
|
201
|
+
const live = (await listChats()).find((x) => x.id === meta.id);
|
|
202
|
+
return { ...c, chatId: meta.id, busy: live?.busy ?? false, awaitingApproval: live?.awaitingApproval ?? false, updatedAt: meta.updatedAt };
|
|
203
|
+
};
|
|
204
|
+
let body = {};
|
|
205
|
+
if (method === "POST" || method === "PUT") {
|
|
206
|
+
try {
|
|
207
|
+
const raw = await readBody(req);
|
|
208
|
+
if (raw)
|
|
209
|
+
body = JSON.parse(raw);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return json(res, { error: "invalid JSON body" }, 400);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
if (seg.length === 2 && method === "GET") {
|
|
217
|
+
const channels = await listChannels();
|
|
218
|
+
return json(res, { root: await channelsRoot(), channels: await Promise.all(channels.map(view)) });
|
|
219
|
+
}
|
|
220
|
+
if (seg.length === 2 && method === "POST") {
|
|
221
|
+
const { chatId: _c, slug: _s, ...input } = body;
|
|
222
|
+
return json(res, await view(await saveChannel(input)));
|
|
223
|
+
}
|
|
224
|
+
if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
|
|
225
|
+
const { chatId, slug: _s, ...input } = body;
|
|
226
|
+
if (!chatId)
|
|
227
|
+
return json(res, { error: "chatId is required" }, 400);
|
|
228
|
+
const channel = await saveChannel(input);
|
|
229
|
+
const converted = await convertChatToChannel(chatId, channel);
|
|
230
|
+
if (converted.error) {
|
|
231
|
+
await deleteChannel(channel.slug).catch(() => { });
|
|
232
|
+
return json(res, { error: converted.error }, 409);
|
|
233
|
+
}
|
|
234
|
+
return json(res, await view(channel));
|
|
235
|
+
}
|
|
236
|
+
if (seg.length === 3) {
|
|
237
|
+
const slug = decodeURIComponent(seg[2]);
|
|
238
|
+
const channel = await getChannel(slug);
|
|
239
|
+
if (!channel)
|
|
240
|
+
return json(res, { error: "channel not found" }, 404);
|
|
241
|
+
if (method === "GET")
|
|
242
|
+
return json(res, await view(channel));
|
|
243
|
+
if (method === "PUT") {
|
|
244
|
+
const { chatId: _c, slug: _s, ...input } = body;
|
|
245
|
+
const saved = await saveChannel({ ...input, slug });
|
|
246
|
+
const meta = await ensureChannelChat(saved);
|
|
247
|
+
await dropChannelSeats(meta.id, saved.members);
|
|
248
|
+
return json(res, await view(saved));
|
|
249
|
+
}
|
|
250
|
+
if (method === "DELETE") {
|
|
251
|
+
const meta = (await listChats()).find((c) => c.scope.kind === "channel" && c.scope.slug === slug);
|
|
252
|
+
if (meta)
|
|
253
|
+
await deleteChat(meta.id);
|
|
254
|
+
await deleteChannel(slug);
|
|
255
|
+
return json(res, { ok: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return json(res, { error: err.message }, 400);
|
|
261
|
+
}
|
|
262
|
+
return json(res, { error: "not found" }, 404);
|
|
263
|
+
}
|
|
193
264
|
// GET /api/notifications — the bell: attention items, newest first, plus unread count.
|
|
194
265
|
// POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
|
|
195
266
|
// DELETE /api/notifications — clear the list.
|
|
@@ -914,7 +985,8 @@ async function handleApi(req, res, url) {
|
|
|
914
985
|
const text = String(body.text ?? "").trim();
|
|
915
986
|
if (!text)
|
|
916
987
|
return json(res, { error: "text is required" }, 400);
|
|
917
|
-
|
|
988
|
+
// Channels: the poster's display name signs the message.
|
|
989
|
+
result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
|
|
918
990
|
}
|
|
919
991
|
else if (seg[3] === "permission") {
|
|
920
992
|
result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
|