@netnodeag/kraftwerk 0.39.1 → 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 (41) hide show
  1. package/README.md +72 -4
  2. package/dist/config.d.ts +7 -0
  3. package/dist/config.js +18 -0
  4. package/dist/inspector/channels.d.ts +48 -0
  5. package/dist/inspector/channels.js +130 -0
  6. package/dist/inspector/chat/acp.js +17 -0
  7. package/dist/inspector/chat/backend.d.ts +6 -0
  8. package/dist/inspector/chat/permissions.d.ts +36 -0
  9. package/dist/inspector/chat/permissions.js +42 -0
  10. package/dist/inspector/chat/sessions.d.ts +20 -1
  11. package/dist/inspector/chat/sessions.js +384 -59
  12. package/dist/inspector/chat/types.d.ts +18 -0
  13. package/dist/inspector/diagnose.d.ts +32 -0
  14. package/dist/inspector/diagnose.js +80 -0
  15. package/dist/inspector/git.js +17 -4
  16. package/dist/inspector/notifications.d.ts +69 -0
  17. package/dist/inspector/notifications.js +91 -0
  18. package/dist/inspector/routines.d.ts +2 -0
  19. package/dist/inspector/routines.js +15 -1
  20. package/dist/inspector/runner.js +20 -1
  21. package/dist/inspector/server.js +117 -2
  22. package/dist/inspector/settings.js +5 -1
  23. package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-BO4gDbWB.js} +1 -1
  24. package/inspector/dist/assets/{dist-D01HLRsD.js → dist-BiIrAs8U.js} +1 -1
  25. package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-Bj7XitTo.js} +1 -1
  26. package/inspector/dist/assets/{dist-DQujkoko.js → dist-BvHfHbzl.js} +1 -1
  27. package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-CASrSR5E.js} +1 -1
  28. package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-CoT4dTT9.js} +1 -1
  29. package/inspector/dist/assets/{dist-BAHymHVh.js → dist-CqbReWDG.js} +1 -1
  30. package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DKds1beX.js} +1 -1
  31. package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
  32. package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-g0pveAaF.js} +1 -1
  33. package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-h7tJ3T-Z.js} +1 -1
  34. package/inspector/dist/assets/{editor-CVAfyi-9.js → editor-BiyVJ7Wz.js} +3 -3
  35. package/inspector/dist/assets/index-CHI0eOQa.css +1 -0
  36. package/inspector/dist/assets/index-lwVGUk9H.js +218 -0
  37. package/inspector/dist/index.html +2 -2
  38. package/package.json +1 -1
  39. package/inspector/dist/assets/dist-CWxCXHGC.js +0 -1
  40. package/inspector/dist/assets/index-Ba-uGp4N.js +0 -218
  41. package/inspector/dist/assets/index-CL7d8WY1.css +0 -1
@@ -10,12 +10,78 @@ import { listRepos } from "../repos.js";
10
10
  import { resolveVibeable, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
11
11
  import { startAcpBackend } from "./acp.js";
12
12
  import { startPiBackend } from "./pi.js";
13
+ import { UNATTENDED_PERMISSION_TIMEOUT_MS, declineOption, unattendedTimeoutLabel } from "./permissions.js";
14
+ import { chatHref, dismissKey, pushNotification } from "../notifications.js";
15
+ import { getChannel, mentionTargets } from "../channels.js";
13
16
  import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
17
+ const MAIN = "main";
14
18
  const states = new Map();
15
- /** Kill a chat's agent subprocess; the next message spawns a fresh one. */
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 });
40
+ /** Routine-fired sessions: nobody is expected to be watching live. */
41
+ const isUnattended = (scope) => scope.kind === "agent" && !!scope.routine;
42
+ /** Diagnose handle for a failed routine session (only called for unattended agent scopes). */
43
+ function routineRef(meta) {
44
+ const scope = meta.scope;
45
+ if (scope.kind !== "agent" || !scope.routine)
46
+ return undefined;
47
+ return { kind: "routine", agent: scope.slug, routine: scope.routine, chatId: meta.id };
48
+ }
49
+ /** How the bell names a chat: its title ("⏰ Morning digest"), else the agent, else "chat". */
50
+ function chatLabel(meta) {
51
+ if (meta.scope.kind === "channel")
52
+ return `#${meta.scope.slug}`;
53
+ if (meta.title)
54
+ return meta.title;
55
+ return meta.scope.kind === "agent" ? meta.scope.slug : "chat";
56
+ }
57
+ /** Text of the agent's final message (or last error) after `afterSeq`, for a notification body. */
58
+ function lastEventText(state, afterSeq, type) {
59
+ const parts = [];
60
+ for (const e of state.events) {
61
+ if (e.seq <= afterSeq)
62
+ continue;
63
+ if (e.type === "user_message")
64
+ parts.length = 0;
65
+ if (type === "text" && e.type === "text")
66
+ parts.push(e.text);
67
+ if (type === "error" && e.type === "error")
68
+ parts.push(e.message);
69
+ }
70
+ const text = parts.join("").trim();
71
+ if (!text)
72
+ return undefined;
73
+ // Agents end with the summary: keep the last paragraph(s), the bell clips the rest.
74
+ const paras = text.split(/\n{2,}/).filter((p) => p.trim());
75
+ return paras.slice(-2).join("\n\n");
76
+ }
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. */
16
82
  function dropBackend(state) {
17
- state.backend?.dispose();
18
- state.backend = null;
83
+ for (const seat of state.seats.values())
84
+ dropSeat(seat);
19
85
  }
20
86
  // Idle reaper: a finished chat must not keep its agent subprocess alive
21
87
  // forever (one ACP adapter + one harness binary per chat adds up fast —
@@ -27,7 +93,8 @@ const IDLE_BACKEND_MS = 15 * 60_000;
27
93
  const reaper = setInterval(() => {
28
94
  const cutoff = Date.now() - IDLE_BACKEND_MS;
29
95
  for (const state of states.values()) {
30
- 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) {
31
98
  dropBackend(state);
32
99
  }
33
100
  }
@@ -49,20 +116,19 @@ async function loadState(id) {
49
116
  meta,
50
117
  events: await readEvents(id),
51
118
  subscribers: new Set(),
52
- backend: null,
53
- busy: false,
119
+ seats: new Map(),
54
120
  pendingPermissions: new Map(),
55
121
  writeChain: Promise.resolve(),
56
- needsContext: true,
57
122
  };
58
123
  // Two racing loads: keep whichever registered first.
59
124
  return states.get(id) ?? (states.set(id, state), state);
60
125
  }
61
- function emit(state, ev) {
126
+ function emit(state, ev, from) {
62
127
  const stored = {
63
128
  ...ev,
64
129
  seq: (state.events[state.events.length - 1]?.seq ?? 0) + 1,
65
130
  ts: new Date().toISOString(),
131
+ ...(from ? { from } : {}),
66
132
  };
67
133
  state.events.push(stored);
68
134
  state.meta.updatedAt = stored.ts;
@@ -254,8 +320,12 @@ async function baseScopeContext(scope, agent) {
254
320
  `say so and suggest which agent or tool fits better.` +
255
321
  (scope.routine
256
322
  ? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
257
- `nobody may be watching live. Complete the task autonomously (tool permissions are ` +
258
- `auto-approved) and end with a clear, self-contained summary of what you did and found.`
323
+ `nobody may be watching live. Complete the task autonomously. Edits inside the project ` +
324
+ `need no approval; anything your harness asks about (shell commands, network, files ` +
325
+ `outside the project) is shown to a human who may look later — say in one line why you ` +
326
+ `need it, then continue with what you can. A request nobody answers within ` +
327
+ `${unattendedTimeoutLabel()} is declined: report what you could not do and why, do ` +
328
+ `not retry it another way. End with a clear, self-contained summary of what you did and found.`
259
329
  : ""));
260
330
  }
261
331
  if (scope.kind === "knowledge") {
@@ -352,25 +422,57 @@ const RENDERING_BLOCK = `## Chat rendering\n` +
352
422
  `with ![alt](/api/runs/<run-id>/file?name=picture.png&raw=1), or link any artifact the same way. ` +
353
423
  `Prefer these relative /api/... URLs over file paths when showing results: they render directly ` +
354
424
  `in this chat and keep working wherever the inspector is reachable.`;
355
- async function scopeContext(meta) {
356
- 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;
357
430
  // The repositories block reads every clone from git, so it runs alongside
358
431
  // the rest instead of adding its spawns to the first prompt's latency.
359
- const [base, repos, vibe, skills] = await Promise.all([
432
+ const [base, repos, vibe, skills, channel] = await Promise.all([
360
433
  baseScopeContext(scope, agent),
361
434
  scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
362
435
  meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
363
436
  availableSkills(scope),
437
+ meta.scope.kind === "channel" ? channelContext(meta.scope.slug, seat.key) : Promise.resolve(""),
364
438
  ]);
365
- 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.`);
366
466
  }
367
467
  /* ---------- backend lifecycle ---------- */
368
468
  /** Agent sessions carry the agent's model/effort; resolved live so edits apply to new backends. */
369
- async function backendTuning(meta) {
370
- const { scope, agent } = meta;
469
+ async function backendTuning(meta, seat) {
470
+ const { scope } = meta;
471
+ const agent = seat.harness;
371
472
  const tuning = {};
372
- if (scope.kind === "agent") {
373
- 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);
374
476
  if (def?.model)
375
477
  tuning.model = def.model;
376
478
  if (def?.effort)
@@ -384,6 +486,9 @@ async function backendTuning(meta) {
384
486
  // A vibeable session works inside the app folder for the same reason.
385
487
  if ((scope.kind === "run" || meta.vibeable) && agent === "claude")
386
488
  tuning.addDirs = [getProjectRoot()];
489
+ // Routine runs: never a harness mode that skips asking (see unattendedMode in permissions.ts).
490
+ if (isUnattended(scope))
491
+ tuning.unattended = true;
387
492
  // pi loads skills only when told: hand it every visible skill folder.
388
493
  if (agent === "pi") {
389
494
  const skills = await availableSkills(scope);
@@ -392,44 +497,67 @@ async function backendTuning(meta) {
392
497
  }
393
498
  return tuning;
394
499
  }
395
- async function ensureBackend(state) {
396
- if (state.backend)
397
- return state.backend;
500
+ async function ensureBackend(state, seat) {
501
+ if (seat.backend)
502
+ return seat.backend;
503
+ const me = seatAuthor(seat);
398
504
  const hooks = {
399
- emit: (ev) => void emit(state, ev),
505
+ emit: (ev) => void emit(state, ev, me),
400
506
  askPermission: (title, options) => {
401
- // Routine-triggered sessions run unattended: auto-approve, but keep
402
- // the request/resolution pair in the thread as an audit trail.
403
- const scope = state.meta.scope;
404
- if (scope.kind === "agent" && scope.routine) {
405
- const allow = options.find((o) => o.kind?.startsWith("allow")) ?? options[0];
406
- const requestId = randomUUID();
407
- emit(state, { type: "permission_request", requestId, title, options });
408
- emit(state, { type: "permission_resolved", requestId, optionId: allow?.optionId ?? null });
409
- return Promise.resolve(allow?.optionId ?? null);
410
- }
507
+ // The harness already judged this call worth asking about, so the
508
+ // question always goes to a human — routine sessions included. The
509
+ // request stays pending in the thread (and the chat/routine show as
510
+ // waiting) until someone answers. Unattended sessions get a deadline:
511
+ // an unanswered request is declined, never approved, so the routine
512
+ // can wrap up with a summary instead of hanging.
411
513
  return new Promise((resolve) => {
412
514
  const requestId = randomUUID();
515
+ const notifyKey = `approval:${state.meta.id}:${requestId}`;
516
+ let deadline;
413
517
  state.pendingPermissions.set(requestId, (optionId) => {
518
+ if (deadline)
519
+ clearTimeout(deadline);
414
520
  state.pendingPermissions.delete(requestId);
415
- emit(state, { type: "permission_resolved", requestId, optionId });
521
+ emit(state, { type: "permission_resolved", requestId, optionId }, me);
522
+ void dismissKey(notifyKey);
416
523
  resolve(optionId);
417
524
  });
418
- emit(state, { type: "permission_request", requestId, title, options });
525
+ emit(state, { type: "permission_request", requestId, title, options }, me);
526
+ // The bell: a question is waiting. Answered -> the item goes away again.
527
+ void pushNotification({
528
+ kind: "approval",
529
+ key: notifyKey,
530
+ title: `${chatLabel(state.meta)}${me?.kind === "agent" ? ` · @${me.slug}` : ""} needs approval`,
531
+ body: title,
532
+ href: chatHref(state.meta),
533
+ });
534
+ if (isUnattended(state.meta.scope)) {
535
+ deadline = setTimeout(() => {
536
+ const pending = state.pendingPermissions.get(requestId);
537
+ if (!pending)
538
+ return;
539
+ emit(state, {
540
+ type: "error",
541
+ message: `nobody answered this permission request within ${unattendedTimeoutLabel()} — declined (unattended routine session)`,
542
+ });
543
+ pending(declineOption(options));
544
+ }, UNATTENDED_PERMISSION_TIMEOUT_MS);
545
+ deadline.unref?.();
546
+ }
419
547
  });
420
548
  },
421
549
  };
422
- const { agent } = state.meta;
550
+ const agent = seat.harness;
423
551
  const cwd = await effectiveCwd(state.meta);
424
- const tuning = await backendTuning(state.meta);
425
- state.backend =
552
+ const tuning = await backendTuning(state.meta, seat);
553
+ seat.backend =
426
554
  agent === "pi"
427
555
  ? startPiBackend(cwd, hooks, tuning)
428
556
  : await startAcpBackend(agent, cwd, hooks, tuning);
429
557
  // Fresh process = fresh context window: re-send scope context with the
430
558
  // next prompt (matters for chats resumed after an inspector restart).
431
- state.needsContext = true;
432
- return state.backend;
559
+ seat.needsContext = true;
560
+ return seat.backend;
433
561
  }
434
562
  /* ---------- public API (used by server.ts) ---------- */
435
563
  /** Where a chat's agent runs when no vibeable is open: the run folder for run chats, else the project root. */
@@ -463,47 +591,53 @@ export async function createChat(opts) {
463
591
  meta,
464
592
  events: [],
465
593
  subscribers: new Set(),
466
- backend: null,
467
- busy: false,
594
+ seats: new Map(),
468
595
  pendingPermissions: new Map(),
469
596
  writeChain: Promise.resolve(),
470
- needsContext: true,
471
597
  });
472
598
  return meta;
473
599
  }
600
+ /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
601
+ export function chatAwaitingApproval(id) {
602
+ return (states.get(id)?.pendingPermissions.size ?? 0) > 0;
603
+ }
474
604
  export async function listChats() {
475
605
  const metas = await listChatMetas();
476
606
  return metas.map((m) => ({
477
607
  ...(states.get(m.id)?.meta ?? m),
478
- busy: states.get(m.id)?.busy ?? false,
608
+ busy: states.has(m.id) ? isBusy(states.get(m.id)) : false,
609
+ awaitingApproval: chatAwaitingApproval(m.id),
479
610
  }));
480
611
  }
481
612
  export async function getChat(id) {
482
613
  const state = await loadState(id);
483
614
  if (!state)
484
615
  return null;
485
- return { meta: state.meta, events: state.events, busy: state.busy };
616
+ return { meta: state.meta, events: state.events, busy: isBusy(state) };
486
617
  }
487
- export async function postMessage(id, text) {
618
+ export async function postMessage(id, text, opts = {}) {
488
619
  const state = await loadState(id);
489
620
  if (!state)
490
621
  return { error: "not found" };
491
- 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)
492
626
  return { error: "agent is still working — wait for the turn to finish" };
493
- state.busy = true;
627
+ seat.busy = true;
494
628
  const isFirst = !state.events.some((e) => e.type === "user_message");
495
629
  if (isFirst && !state.meta.title) {
496
630
  state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
497
631
  void writeMeta(state.meta).catch(() => { });
498
632
  }
499
- emit(state, { type: "user_message", text });
633
+ const turnStart = emit(state, { type: "user_message", text }).seq;
500
634
  // The turn runs in the background; the HTTP request returns immediately
501
635
  // and the browser follows along on the SSE stream.
502
636
  void (async () => {
503
637
  try {
504
- const backend = await ensureBackend(state);
505
- const context = state.needsContext ? await scopeContext(state.meta) : "";
506
- state.needsContext = false;
638
+ const backend = await ensureBackend(state, seat);
639
+ const context = seat.needsContext ? await scopeContext(state.meta, seat) : "";
640
+ seat.needsContext = false;
507
641
  // "/<skill> args" expands to the skill's instructions for the agent;
508
642
  // the thread keeps the short form the user typed.
509
643
  const body = await expandSkillInvocation(state.meta.scope, text);
@@ -511,24 +645,213 @@ export async function postMessage(id, text) {
511
645
  const stopReason = await backend.prompt(promptText);
512
646
  emit(state, { type: "turn_end", stopReason });
513
647
  // Routine sessions are one-shot and unattended: release the agent
514
- // process as soon as the turn ends instead of waiting for the reaper.
515
- const scope = state.meta.scope;
516
- if (scope.kind === "agent" && scope.routine)
648
+ // process as soon as the turn ends instead of waiting for the reaper,
649
+ // and tell the bell how it went.
650
+ if (isUnattended(state.meta.scope)) {
517
651
  dropBackend(state);
652
+ const failed = state.events.some((e) => e.seq > turnStart && e.type === "error");
653
+ void pushNotification({
654
+ kind: failed ? "routine_failed" : "routine_done",
655
+ title: `${chatLabel(state.meta)} ${failed ? "ended with an error" : "finished"}`,
656
+ body: failed ? lastEventText(state, turnStart, "error") : lastEventText(state, turnStart, "text"),
657
+ href: chatHref(state.meta),
658
+ ...(failed ? { diagnose: routineRef(state.meta) } : {}),
659
+ });
660
+ }
518
661
  }
519
662
  catch (err) {
520
663
  emit(state, { type: "error", message: err.message });
664
+ if (isUnattended(state.meta.scope)) {
665
+ void pushNotification({
666
+ kind: "routine_failed",
667
+ title: `${chatLabel(state.meta)} failed`,
668
+ body: err.message,
669
+ href: chatHref(state.meta),
670
+ diagnose: routineRef(state.meta),
671
+ });
672
+ }
521
673
  // A failed turn may mean a dead subprocess; drop it so the next
522
674
  // message spawns a fresh agent (thread history stays on disk).
523
675
  dropBackend(state);
524
676
  }
525
677
  finally {
526
- state.busy = false;
678
+ seat.busy = false;
527
679
  void writeMeta(state.meta).catch(() => { });
528
680
  }
529
681
  })();
530
682
  return {};
531
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
+ }
532
855
  /**
533
856
  * Open a vibeable in the chat (or close it with null). The agent's cwd
534
857
  * becomes the app folder, so the running subprocess is dropped and the next
@@ -540,7 +863,7 @@ export async function setChatVibeable(id, slug) {
540
863
  if (!state)
541
864
  return { error: "not found", status: 404 };
542
865
  const busyError = { error: "agent is still working — wait for the turn to finish", status: 409 };
543
- if (state.busy)
866
+ if (isBusy(state))
544
867
  return busyError;
545
868
  let dir = null;
546
869
  if (slug) {
@@ -555,7 +878,7 @@ export async function setChatVibeable(id, slug) {
555
878
  // A message may have started a turn during the await above; its backend
556
879
  // was spawned in the old cwd, so refuse rather than relabel it. From here
557
880
  // on nothing yields until cwd, vibeable and backend agree.
558
- if (state.busy)
881
+ if (isBusy(state))
559
882
  return busyError;
560
883
  if (slug && dir) {
561
884
  state.meta.vibeable = slug;
@@ -567,7 +890,8 @@ export async function setChatVibeable(id, slug) {
567
890
  }
568
891
  state.meta.updatedAt = new Date().toISOString();
569
892
  dropBackend(state);
570
- state.needsContext = true;
893
+ for (const seat of state.seats.values())
894
+ seat.needsContext = true;
571
895
  await writeMeta(state.meta);
572
896
  return { meta: state.meta };
573
897
  }
@@ -599,7 +923,8 @@ export async function cancelChat(id) {
599
923
  return { error: "not found" };
600
924
  for (const resolve of state.pendingPermissions.values())
601
925
  resolve(null);
602
- state.backend?.cancel();
926
+ for (const seat of state.seats.values())
927
+ seat.backend?.cancel();
603
928
  return {};
604
929
  }
605
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
  };