@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.
Files changed (25) hide show
  1. package/README.md +43 -0
  2. package/dist/inspector/channels.d.ts +48 -0
  3. package/dist/inspector/channels.js +130 -0
  4. package/dist/inspector/chat/sessions.d.ts +17 -1
  5. package/dist/inspector/chat/sessions.js +280 -44
  6. package/dist/inspector/chat/types.d.ts +18 -0
  7. package/dist/inspector/notifications.js +2 -0
  8. package/dist/inspector/server.js +74 -2
  9. package/inspector/dist/assets/{dist-HuQTcyVK.js → dist-BO4gDbWB.js} +1 -1
  10. package/inspector/dist/assets/{dist-Bv3fOisq.js → dist-BiIrAs8U.js} +1 -1
  11. package/inspector/dist/assets/{dist-uQIhtGKj.js → dist-Bj7XitTo.js} +1 -1
  12. package/inspector/dist/assets/{dist-D9-MFS0x.js → dist-BvHfHbzl.js} +1 -1
  13. package/inspector/dist/assets/{dist-Dyv2EX0S.js → dist-CASrSR5E.js} +1 -1
  14. package/inspector/dist/assets/{dist-XL4xXfDo.js → dist-CoT4dTT9.js} +1 -1
  15. package/inspector/dist/assets/{dist-Cex4_N6v.js → dist-CqbReWDG.js} +1 -1
  16. package/inspector/dist/assets/{dist-DdbyO9w9.js → dist-DKds1beX.js} +1 -1
  17. package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
  18. package/inspector/dist/assets/{dist-AXonJXol.js → dist-g0pveAaF.js} +1 -1
  19. package/inspector/dist/assets/{dist-BJLTdJRF.js → dist-h7tJ3T-Z.js} +1 -1
  20. package/inspector/dist/assets/{editor--4qv8VP5.js → editor-BiyVJ7Wz.js} +3 -3
  21. package/inspector/dist/assets/{index-E1E7-_NW.css → index-CHI0eOQa.css} +1 -1
  22. package/inspector/dist/assets/{index-BSqc9eg2.js → index-lwVGUk9H.js} +17 -17
  23. package/inspector/dist/index.html +2 -2
  24. package/package.json +1 -1
  25. 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
- /** Kill a chat's agent subprocess; the next message spawns a fresh one. */
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.backend?.dispose();
55
- state.backend = null;
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
- if (state.backend && !state.busy && Date.parse(state.meta.updatedAt) < cutoff) {
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
- backend: null,
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 ![alt](/api/runs/<run-id>/file?name=picture.png&raw=1), 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
- const { scope, agent } = meta;
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, agent } = meta;
469
+ async function backendTuning(meta, seat) {
470
+ const { scope } = meta;
471
+ const agent = seat.harness;
412
472
  const tuning = {};
413
- if (scope.kind === "agent") {
414
- const def = await getAgent(scope.slug).catch(() => null);
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 (state.backend)
441
- return state.backend;
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 { agent } = state.meta;
550
+ const agent = seat.harness;
489
551
  const cwd = await effectiveCwd(state.meta);
490
- const tuning = await backendTuning(state.meta);
491
- state.backend =
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
- state.needsContext = true;
498
- return state.backend;
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
- backend: null,
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)?.busy ?? false,
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.busy };
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.busy)
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
- state.busy = true;
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 = state.needsContext ? await scopeContext(state.meta) : "";
577
- state.needsContext = false;
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
- state.busy = false;
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.busy)
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.busy)
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.needsContext = true;
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.backend?.cancel();
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) {
@@ -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 { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, } from "./chat/sessions.js";
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
- result = await postMessage(seg[2], text);
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));