@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.
- package/README.md +72 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -0
- package/dist/inspector/channels.d.ts +48 -0
- package/dist/inspector/channels.js +130 -0
- package/dist/inspector/chat/acp.js +17 -0
- package/dist/inspector/chat/backend.d.ts +6 -0
- package/dist/inspector/chat/permissions.d.ts +36 -0
- package/dist/inspector/chat/permissions.js +42 -0
- package/dist/inspector/chat/sessions.d.ts +20 -1
- package/dist/inspector/chat/sessions.js +384 -59
- package/dist/inspector/chat/types.d.ts +18 -0
- package/dist/inspector/diagnose.d.ts +32 -0
- package/dist/inspector/diagnose.js +80 -0
- package/dist/inspector/git.js +17 -4
- package/dist/inspector/notifications.d.ts +69 -0
- package/dist/inspector/notifications.js +91 -0
- package/dist/inspector/routines.d.ts +2 -0
- package/dist/inspector/routines.js +15 -1
- package/dist/inspector/runner.js +20 -1
- package/dist/inspector/server.js +117 -2
- package/dist/inspector/settings.js +5 -1
- package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-BO4gDbWB.js} +1 -1
- package/inspector/dist/assets/{dist-D01HLRsD.js → dist-BiIrAs8U.js} +1 -1
- package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-Bj7XitTo.js} +1 -1
- package/inspector/dist/assets/{dist-DQujkoko.js → dist-BvHfHbzl.js} +1 -1
- package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-CASrSR5E.js} +1 -1
- package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-CoT4dTT9.js} +1 -1
- package/inspector/dist/assets/{dist-BAHymHVh.js → dist-CqbReWDG.js} +1 -1
- package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DKds1beX.js} +1 -1
- package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
- package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-g0pveAaF.js} +1 -1
- package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-h7tJ3T-Z.js} +1 -1
- package/inspector/dist/assets/{editor-CVAfyi-9.js → editor-BiyVJ7Wz.js} +3 -3
- package/inspector/dist/assets/index-CHI0eOQa.css +1 -0
- package/inspector/dist/assets/index-lwVGUk9H.js +218 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-CWxCXHGC.js +0 -1
- package/inspector/dist/assets/index-Ba-uGp4N.js +0 -218
- 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
|
-
|
|
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.
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
`
|
|
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 , 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
|
-
|
|
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
|
|
469
|
+
async function backendTuning(meta, seat) {
|
|
470
|
+
const { scope } = meta;
|
|
471
|
+
const agent = seat.harness;
|
|
371
472
|
const tuning = {};
|
|
372
|
-
|
|
373
|
-
|
|
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 (
|
|
397
|
-
return
|
|
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
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
550
|
+
const agent = seat.harness;
|
|
423
551
|
const cwd = await effectiveCwd(state.meta);
|
|
424
|
-
const tuning = await backendTuning(state.meta);
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
return
|
|
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
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
506
|
-
|
|
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
|
-
|
|
516
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
};
|