pipeline-moe 0.1.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/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- package/web/tsconfig.json +21 -0
package/src/sse.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Minimal Server-Sent Events hub. One process-wide hub; every connected client
|
|
2
|
+
// receives every event. Payloads are small and JSON-serialisable.
|
|
3
|
+
|
|
4
|
+
import type { Response } from "express"
|
|
5
|
+
|
|
6
|
+
export type SseEventName =
|
|
7
|
+
| "roster" // full roster snapshot
|
|
8
|
+
| "message" // a completed transcript line (user or agent)
|
|
9
|
+
| "token" // a streaming text delta from an agent
|
|
10
|
+
| "activity" // a tool-call start/end from an agent (live process visibility)
|
|
11
|
+
| "reasoning" // a streaming thinking delta from an agent (ephemeral)
|
|
12
|
+
| "status" // a participant status change
|
|
13
|
+
| "receipt" // a work receipt (filesystem diff)
|
|
14
|
+
| "notice" // an informational/error notice
|
|
15
|
+
| "turn" // a routing turn lifecycle marker (phases: start, end, chain, parallel, pause, resume)
|
|
16
|
+
| "workspace" // live workspace file listing
|
|
17
|
+
| "circuit_breaker" // agent repeated similar output N times — pipeline aborted
|
|
18
|
+
| "settings" // room settings change (e.g. chaining toggle)
|
|
19
|
+
| "routing" // semi/manual routing: a handoff proposal awaiting approval, or its resolution
|
|
20
|
+
| "transcript" // full transcript replacement (on conversation switch)
|
|
21
|
+
| "conversations" // saved-conversation list + current id
|
|
22
|
+
| "providers" // provider auth status changed (after add/remove)
|
|
23
|
+
| "oauth_progress" // OAuth login progress (device code, success, error)
|
|
24
|
+
| "room" // room lifecycle event (created, destroyed)
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SSE_MAX_CLIENTS = 10
|
|
27
|
+
|
|
28
|
+
export class SseHub {
|
|
29
|
+
/**
|
|
30
|
+
* Map from Response to the roomId this client is subscribed to.
|
|
31
|
+
* undefined = global subscriber (receives all events).
|
|
32
|
+
* string = room-filtered subscriber (receives events for that room + global events).
|
|
33
|
+
*/
|
|
34
|
+
private clients = new Map<Response, string | undefined>()
|
|
35
|
+
|
|
36
|
+
constructor(readonly maxClients: number = DEFAULT_SSE_MAX_CLIENTS) {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register a new SSE client.
|
|
40
|
+
* @param res Express response to write events to.
|
|
41
|
+
* @param roomId Optional room filter. If set, this client only receives events
|
|
42
|
+
* tagged with matching roomId, plus events with no roomId tag (global).
|
|
43
|
+
*/
|
|
44
|
+
addClient(res: Response, roomId?: string): void {
|
|
45
|
+
if (this.clients.size >= this.maxClients) {
|
|
46
|
+
res.writeHead(429, "Too Many SSE Connections")
|
|
47
|
+
res.end()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
res.setHeader("Content-Type", "text/event-stream")
|
|
51
|
+
res.setHeader("Cache-Control", "no-cache, no-transform")
|
|
52
|
+
res.setHeader("Connection", "keep-alive")
|
|
53
|
+
res.setHeader("X-Accel-Buffering", "no")
|
|
54
|
+
res.flushHeaders?.()
|
|
55
|
+
res.write(`: connected\n\n`)
|
|
56
|
+
this.clients.set(res, roomId)
|
|
57
|
+
res.on("close", () => this.clients.delete(res))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Broadcast an event to subscribers.
|
|
62
|
+
* @param roomId Optional originating room. If set, only clients subscribed to
|
|
63
|
+
* that room (or global, unfiltered clients) receive it. If omitted,
|
|
64
|
+
* the event reaches everyone — used for process-global events like
|
|
65
|
+
* room lifecycle (created/destroyed/renamed).
|
|
66
|
+
*/
|
|
67
|
+
broadcast(event: SseEventName, data: unknown, roomId?: string): void {
|
|
68
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
69
|
+
for (const [res, clientRoomId] of this.clients) {
|
|
70
|
+
// Skip if this client has a room filter AND the event targets a different room.
|
|
71
|
+
// Events with no roomId (global lifecycle events) reach everyone.
|
|
72
|
+
if (clientRoomId !== undefined && roomId !== undefined && clientRoomId !== roomId) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
res.write(frame)
|
|
77
|
+
} catch {
|
|
78
|
+
this.clients.delete(res)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get clientCount(): number {
|
|
84
|
+
return this.clients.size
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/sshfs.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SSHFS room scope — mount a remote directory (user@host:/path) as a local
|
|
2
|
+
// FUSE mountpoint so a room can be scoped to a directory on another machine.
|
|
3
|
+
//
|
|
4
|
+
// The mount is transparent to everything downstream: the sandbox, work-receipt
|
|
5
|
+
// snapshots, and the workspace listing all see a plain local directory. Only
|
|
6
|
+
// the mount/unmount lifecycle lives here.
|
|
7
|
+
//
|
|
8
|
+
// Caveat: bash still runs LOCALLY with the mountpoint as cwd. File I/O (read,
|
|
9
|
+
// write, edit, grep, find) traverses FUSE to the remote host, but processes
|
|
10
|
+
// (node, python, git) execute on this machine. This is "Option A" — remote
|
|
11
|
+
// files, local execution. Remote execution would be a different architecture.
|
|
12
|
+
|
|
13
|
+
import { execFile } from "node:child_process"
|
|
14
|
+
import { promisify } from "node:util"
|
|
15
|
+
import { mkdir, rmdir, readdir } from "node:fs/promises"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
|
|
20
|
+
/** Base directory under which per-room mountpoints are created. */
|
|
21
|
+
export const MOUNT_BASE = "/tmp/pipeline-moe-mounts"
|
|
22
|
+
|
|
23
|
+
/** Mount metadata recorded for an sshfs-scoped room. */
|
|
24
|
+
export interface RoomMount {
|
|
25
|
+
/** Local FUSE mountpoint used as the room's workspaceDir. */
|
|
26
|
+
mountpoint: string
|
|
27
|
+
/** The original user@host:/path target, for display/diagnostics. */
|
|
28
|
+
sshTarget: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Matches an sshfs target: user@host:/path (path may be absolute or relative). */
|
|
32
|
+
const SSH_TARGET_RE = /^[^@\s]+@[^:\s]+:.+$/
|
|
33
|
+
|
|
34
|
+
/** True when the string looks like an sshfs target (user@host:/path), not a
|
|
35
|
+
* local path. A local path (absolute or relative) has no `user@host:` prefix. */
|
|
36
|
+
export function isSshTarget(s: string): boolean {
|
|
37
|
+
return SSH_TARGET_RE.test(s.trim())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The deterministic local mountpoint for a room. roomId is already sanitised
|
|
41
|
+
* upstream ([a-zA-Z0-9_-] only), so this cannot escape MOUNT_BASE. */
|
|
42
|
+
export function mountpointFor(roomId: string): string {
|
|
43
|
+
return join(MOUNT_BASE, roomId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether the sshfs binary is available on this machine. */
|
|
47
|
+
export async function sshfsAvailable(): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
await execFileAsync("sshfs", ["--version"])
|
|
50
|
+
return true
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mount an sshfs target at the room's deterministic mountpoint and return the
|
|
58
|
+
* local mountpoint path (to be used as the room's workspaceDir).
|
|
59
|
+
*
|
|
60
|
+
* Throws with a clear message when:
|
|
61
|
+
* - the target is not a valid user@host:/path string
|
|
62
|
+
* - sshfs is not installed
|
|
63
|
+
* - the mount command fails (key auth, unreachable host, bad remote path)
|
|
64
|
+
*
|
|
65
|
+
* On mount failure the empty mountpoint dir is removed so we don't leak it.
|
|
66
|
+
*/
|
|
67
|
+
export async function mountSshfs(roomId: string, sshTarget: string): Promise<string> {
|
|
68
|
+
const target = sshTarget.trim()
|
|
69
|
+
if (!isSshTarget(target)) {
|
|
70
|
+
throw new Error(`"${target}" is not a valid SSH target (expected user@host:/path)`)
|
|
71
|
+
}
|
|
72
|
+
if (!(await sshfsAvailable())) {
|
|
73
|
+
throw new Error("sshfs is not installed — run `sudo pacman -S sshfs`")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const mountpoint = mountpointFor(roomId)
|
|
77
|
+
// A previous run may have left a live/stale mount at this deterministic path
|
|
78
|
+
// (e.g. the process was SIGKILLed before teardown). Mounting over a leftover
|
|
79
|
+
// FUSE mount fails with "fusermount3: … Permission denied", so clear it
|
|
80
|
+
// best-effort first. Safe: duplicate roomIds are rejected upstream, so no
|
|
81
|
+
// active room owns this mountpoint when we reach here.
|
|
82
|
+
await unmountSshfs(mountpoint).catch(() => {})
|
|
83
|
+
await mkdir(mountpoint, { recursive: true })
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await execFileAsync("sshfs", [
|
|
87
|
+
target,
|
|
88
|
+
mountpoint,
|
|
89
|
+
// BatchMode: fail immediately on auth prompt (no hanging on password).
|
|
90
|
+
// reconnect + keepalive: survive WireGuard blips without dropping the mount.
|
|
91
|
+
"-o",
|
|
92
|
+
"BatchMode=yes,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3",
|
|
93
|
+
])
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Roll back the empty mountpoint dir so a failed mount leaves no trace.
|
|
96
|
+
await rmdir(mountpoint).catch(() => {})
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
98
|
+
throw new Error(`sshfs mount failed for "${target}": ${msg}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return mountpoint
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Unmount an sshfs mountpoint and remove the (now empty) mountpoint dir.
|
|
106
|
+
* Idempotent and best-effort: a mountpoint that is already unmounted or never
|
|
107
|
+
* existed does not throw — teardown must always succeed.
|
|
108
|
+
*/
|
|
109
|
+
export async function unmountSshfs(mountpoint: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await execFileAsync("fusermount3", ["-u", mountpoint])
|
|
112
|
+
} catch {
|
|
113
|
+
// Fall back to fusermount (non-suffixed) on systems without fusermount3.
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync("fusermount", ["-u", mountpoint])
|
|
116
|
+
} catch {
|
|
117
|
+
// Already unmounted, never mounted, or binary missing — nothing to do.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Remove the empty mountpoint dir. Fails harmlessly if still busy or gone.
|
|
121
|
+
await rmdir(mountpoint).catch(() => {})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Unmount every leftover mountpoint under MOUNT_BASE. Run once at startup to
|
|
126
|
+
* clear mounts leaked by a prior process that didn't tear down cleanly (a crash
|
|
127
|
+
* or SIGKILL skips the normal unmount, so the sshfs mount survives and blocks a
|
|
128
|
+
* later re-mount at the same deterministic path). restoreRooms() then re-mounts
|
|
129
|
+
* the rooms that are still valid.
|
|
130
|
+
*/
|
|
131
|
+
export async function cleanupStaleMounts(): Promise<void> {
|
|
132
|
+
let entries: string[]
|
|
133
|
+
try {
|
|
134
|
+
entries = await readdir(MOUNT_BASE)
|
|
135
|
+
} catch {
|
|
136
|
+
return // base dir absent — nothing to clean
|
|
137
|
+
}
|
|
138
|
+
await Promise.all(entries.map((e) => unmountSshfs(join(MOUNT_BASE, e))))
|
|
139
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Conversation persistence: one JSON file per group discussion under
|
|
2
|
+
// config.sessionsDir. The store knows nothing about live sessions — it only
|
|
3
|
+
// reads/writes Conversation snapshots. The Room owns the live<->disk mapping.
|
|
4
|
+
|
|
5
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { assertInside } from "./path-guard.js"
|
|
8
|
+
import { config } from "./config.js"
|
|
9
|
+
import type { Conversation, ConversationMeta } from "./types.js"
|
|
10
|
+
|
|
11
|
+
export function conversationMeta(conv: Conversation): ConversationMeta {
|
|
12
|
+
return {
|
|
13
|
+
id: conv.id,
|
|
14
|
+
title: conv.title,
|
|
15
|
+
createdAt: conv.createdAt,
|
|
16
|
+
updatedAt: conv.updatedAt,
|
|
17
|
+
messageCount: conv.transcript.length,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ConversationStore {
|
|
22
|
+
constructor(private readonly dir: string = config.sessionsDir) {}
|
|
23
|
+
|
|
24
|
+
async init(): Promise<void> {
|
|
25
|
+
await mkdir(this.dir, { recursive: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private file(id: string): string {
|
|
29
|
+
const path = join(this.dir, `${id}.json`)
|
|
30
|
+
assertInside(this.dir, path)
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** All saved conversations as metadata, most-recently-updated first. */
|
|
35
|
+
async list(): Promise<ConversationMeta[]> {
|
|
36
|
+
let names: string[]
|
|
37
|
+
try {
|
|
38
|
+
names = await readdir(this.dir)
|
|
39
|
+
} catch {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
const metas: ConversationMeta[] = []
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
// meta.json is per-room metadata (RoomManager), not a conversation snapshot.
|
|
45
|
+
if (!name.endsWith(".json") || name === "meta.json") continue
|
|
46
|
+
try {
|
|
47
|
+
const conv = JSON.parse(await readFile(join(this.dir, name), "utf8")) as Conversation
|
|
48
|
+
metas.push(conversationMeta(conv))
|
|
49
|
+
} catch {
|
|
50
|
+
// Skip unreadable / half-written files rather than crashing the list.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
metas.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
54
|
+
return metas
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async read(id: string): Promise<Conversation | null> {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(await readFile(this.file(id), "utf8")) as Conversation
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Atomic write (tmp + rename) so the list never sees a partial file. */
|
|
66
|
+
async write(conv: Conversation): Promise<void> {
|
|
67
|
+
const tmp = `${this.file(conv.id)}.tmp`
|
|
68
|
+
await writeFile(tmp, JSON.stringify(conv, null, 2), "utf8")
|
|
69
|
+
await rename(tmp, this.file(conv.id))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async remove(id: string): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
await unlink(this.file(id))
|
|
75
|
+
} catch {
|
|
76
|
+
// Already gone — fine.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Shared types for the Pipeline-MoE backend.
|
|
2
|
+
|
|
3
|
+
/** A persona/agent definition. `tools` is a pi built-in tool allowlist. */
|
|
4
|
+
export interface Persona {
|
|
5
|
+
/** Stable slug, also used as @mention handle (lowercase). */
|
|
6
|
+
id: string
|
|
7
|
+
/** Display name. */
|
|
8
|
+
name: string
|
|
9
|
+
/** Hex color for the UI avatar. */
|
|
10
|
+
color: string
|
|
11
|
+
/** Emoji/icon for the UI avatar. */
|
|
12
|
+
icon: string
|
|
13
|
+
/** pi built-in tool allowlist: read, bash, edit, write, grep, find, ls. */
|
|
14
|
+
tools: string[]
|
|
15
|
+
/** Persona instructions appended to pi's default system prompt. */
|
|
16
|
+
systemPrompt: string
|
|
17
|
+
/** Optional per-agent model as "provider/id". Undefined → the process default
|
|
18
|
+
* (PIPELINE_MODEL / first local). Lets each role run on its own architecture. */
|
|
19
|
+
model?: string
|
|
20
|
+
/** Per-agent thinking/effort level. Undefined → inherit from global PIPELINE_THINKING. */
|
|
21
|
+
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
|
22
|
+
/** Custom instructions for context compaction — tells the summarizer what to preserve vs discard for this role. */
|
|
23
|
+
compactionInstructions?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ParticipantStatus = "idle" | "active" | "thinking" | "working" | "compacting" | "retrying"
|
|
27
|
+
|
|
28
|
+
/** One tool call an agent made during a turn — what it did, not just the result. */
|
|
29
|
+
export interface ToolActivity {
|
|
30
|
+
/** Stable id from pi, used to match the start event with its end. */
|
|
31
|
+
toolCallId: string
|
|
32
|
+
/** e.g. "bash", "read", "write", "edit". */
|
|
33
|
+
toolName: string
|
|
34
|
+
/** The arguments the agent passed (command, path, …). */
|
|
35
|
+
args?: unknown
|
|
36
|
+
status: "running" | "ok" | "error"
|
|
37
|
+
/** Truncated string form of the tool result, set on completion. */
|
|
38
|
+
result?: string
|
|
39
|
+
ts: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A transcript line in the shared room. */
|
|
43
|
+
export interface TranscriptEntry {
|
|
44
|
+
/** Monotonic index into the room transcript. */
|
|
45
|
+
index: number
|
|
46
|
+
/** Author: "user" or a participant id. */
|
|
47
|
+
author: string
|
|
48
|
+
/** Display name of the author. */
|
|
49
|
+
authorName: string
|
|
50
|
+
text: string
|
|
51
|
+
ts: number
|
|
52
|
+
/** Tool calls made while producing this message (agent messages only). */
|
|
53
|
+
activity?: ToolActivity[]
|
|
54
|
+
/** Reasoning trace, if any (agent messages only). */
|
|
55
|
+
reasoning?: string
|
|
56
|
+
/** Paths to saved images (relative to workspace), e.g. "media/abc123.png". */
|
|
57
|
+
images?: string[]
|
|
58
|
+
/** If this message is a question posed to the user via ask_user, the question text. */
|
|
59
|
+
question?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A file-change receipt produced by diffing the workspace around an agent turn. */
|
|
63
|
+
export interface WorkReceipt {
|
|
64
|
+
participantId: string
|
|
65
|
+
created: string[]
|
|
66
|
+
modified: string[]
|
|
67
|
+
deleted: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A persona plus its runtime flags, as persisted inside a conversation. */
|
|
71
|
+
export interface PersonaState extends Persona {
|
|
72
|
+
active: boolean
|
|
73
|
+
/** May run concurrently with adjacent parallel-flagged agents (lane-capped:
|
|
74
|
+
* local agents still serialize on the single llama slot). Optional for
|
|
75
|
+
* back-compat with conversations saved before this existed. */
|
|
76
|
+
parallel?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A saved group conversation: its roster, transcript, and settings. */
|
|
80
|
+
/** How agent→agent handoffs are routed within a room.
|
|
81
|
+
* - `auto` — @mentions chain directly (default).
|
|
82
|
+
* - `semi` — each proposed handoff pauses for human approval before dispatch.
|
|
83
|
+
* - `manual` — no agent→agent chaining; the human routes every step. */
|
|
84
|
+
export type RoutingMode = "auto" | "semi" | "manual"
|
|
85
|
+
|
|
86
|
+
/** A human decision on a proposed handoff (semi/manual routing). */
|
|
87
|
+
export interface RouteDecision {
|
|
88
|
+
action: "approve" | "redirect" | "drop"
|
|
89
|
+
/** For 'redirect': the agent id(s) to route to instead of the proposal. */
|
|
90
|
+
targetIds?: string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface Conversation {
|
|
94
|
+
id: string
|
|
95
|
+
title: string
|
|
96
|
+
createdAt: number
|
|
97
|
+
updatedAt: number
|
|
98
|
+
/** Whether agent→agent chaining was on for this discussion. Derived from
|
|
99
|
+
* `routingMode` (auto/semi → true, manual → false); kept for back-compat. */
|
|
100
|
+
chaining: boolean
|
|
101
|
+
/** Routing mode for this discussion. Absent in older saved conversations —
|
|
102
|
+
* derived from `chaining` on load. */
|
|
103
|
+
routingMode?: RoutingMode
|
|
104
|
+
/** Agent that receives messages with no @mention. null = first active. */
|
|
105
|
+
defaultAgent: string | null
|
|
106
|
+
/** Agent that receives routing fallback when no @mention is found in an agent's reply. null = disabled. */
|
|
107
|
+
fallbackAgent?: string | null
|
|
108
|
+
/** Whether the circuit breaker was enabled for this discussion. Defaults to
|
|
109
|
+
* config.circuitBreaker when absent (back-compat). */
|
|
110
|
+
circuitBreaker?: boolean
|
|
111
|
+
/** Default thinking level for agents without a per-agent override.
|
|
112
|
+
* Defaults to config.thinkingLevel when absent. */
|
|
113
|
+
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
|
114
|
+
/** Whether cloud models are allowed in this discussion.
|
|
115
|
+
* Defaults to config.allowCloud when absent. */
|
|
116
|
+
allowCloud?: boolean
|
|
117
|
+
/** Reserve tokens for auto-compaction. Defaults to 38000 when absent. */
|
|
118
|
+
compactionReserveTokens?: number
|
|
119
|
+
/** The roster (personas + active flags) this discussion ran with. */
|
|
120
|
+
personas: PersonaState[]
|
|
121
|
+
transcript: TranscriptEntry[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Lightweight conversation descriptor for the UI picker. */
|
|
125
|
+
export interface ConversationMeta {
|
|
126
|
+
id: string
|
|
127
|
+
title: string
|
|
128
|
+
createdAt: number
|
|
129
|
+
updatedAt: number
|
|
130
|
+
messageCount: number
|
|
131
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Validation utilities extracted from server.ts for testability.
|
|
2
|
+
|
|
3
|
+
import type { Persona } from "./types.js"
|
|
4
|
+
|
|
5
|
+
export const VALID_TOOLS = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "web_search", "web_read", "youtube_transcript", "arxiv_search", "youcom_search"])
|
|
6
|
+
|
|
7
|
+
function slug(s: string): string {
|
|
8
|
+
return s
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parsePersona(body: Record<string, unknown>): Persona {
|
|
16
|
+
const name = String(body.name ?? "").trim()
|
|
17
|
+
if (!name) throw new Error("`name` is required")
|
|
18
|
+
const id = slug(String(body.id ?? name))
|
|
19
|
+
if (!id) throw new Error("could not derive a valid id from name")
|
|
20
|
+
|
|
21
|
+
const tools = Array.isArray(body.tools)
|
|
22
|
+
? body.tools.map(String).filter((t) => VALID_TOOLS.has(t))
|
|
23
|
+
: ["read", "grep", "find", "ls"]
|
|
24
|
+
|
|
25
|
+
const systemPrompt = String(body.systemPrompt ?? "").trim()
|
|
26
|
+
if (!systemPrompt) throw new Error("`systemPrompt` is required")
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
name,
|
|
31
|
+
color: String(body.color ?? "#888888"),
|
|
32
|
+
icon: String(body.icon ?? "🤖"),
|
|
33
|
+
tools,
|
|
34
|
+
systemPrompt,
|
|
35
|
+
}
|
|
36
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #0c0e14;--bg2: #11141d;--bg3: #161a26;--border: #232838;--text: #d7dbe7;--muted: #7d8499;--accent: #5dcaa5;--danger: #f0726a;font-family:ui-monospace,JetBrains Mono,SF Mono,Menlo,monospace}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--text);font-size:13px}.app{display:grid;grid-template-columns:232px 1fr 260px;height:100vh;overflow:hidden}.sidebar{display:flex;flex-direction:column;background:var(--bg2);border-right:1px solid var(--border);min-height:0}.roster{display:flex;flex-direction:column;flex:1;min-height:0}.roster-head{padding:14px 14px 10px;border-bottom:1px solid var(--border)}.roster-title{color:var(--accent);font-weight:700;letter-spacing:.08em;font-size:12px}.roster-sub{color:var(--muted);font-size:11px;margin-top:4px;display:flex;align-items:center;gap:6px}.dot{width:7px;height:7px;border-radius:50%}.dot-on{background:var(--accent)}.dot-off{background:var(--danger)}.roster-list{display:flex;flex-direction:column;gap:8px;flex:1;overflow:auto;padding:8px}.roster-item{display:flex;align-items:flex-start;gap:9px;padding:12px 10px;border-radius:8px}.roster-item:hover{background:var(--bg3)}.roster-item.inactive{opacity:.45}.roster-item.dragging{opacity:.4}.roster-item.drag-over{background:var(--bg3);box-shadow:inset 0 2px 0 var(--accent)}.drag-handle{flex-shrink:0;cursor:grab;color:var(--muted);font-size:13px;line-height:1;padding:2px 1px;opacity:0;transition:opacity .12s;-webkit-user-select:none;user-select:none}.roster-item:hover .drag-handle{opacity:.6}.drag-handle:hover{opacity:1!important}.drag-handle:active{cursor:grabbing}.avatar{width:28px;height:28px;border-radius:7px;display:grid;place-items:center;font-size:15px;flex-shrink:0;padding-top:2px}.roster-meta{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.roster-top{display:flex;align-items:center;justify-content:space-between;gap:6px}.roster-name-btn{display:flex;align-items:center;gap:5px;min-width:0;background:transparent;border:none;padding:0;cursor:pointer;font-family:inherit;text-align:left}.roster-name-btn:hover .roster-name{text-decoration:underline}.roster-name{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge-default{color:#f5c84b;font-size:11px;flex-shrink:0}.badge-parallel{color:var(--muted);font-size:10px;line-height:1.3;border:1px solid var(--border);border-radius:3px;padding:0 3px;flex-shrink:0}.agent-menu{position:relative;flex-shrink:0}.agent-menu-btn{background:transparent;border:none;color:var(--muted);cursor:pointer;font-size:16px;line-height:1;padding:2px 6px;border-radius:4px}.agent-menu-btn:hover{color:var(--text);background:var(--bg3)}.agent-menu-dropdown{position:fixed;z-index:200;min-width:184px;padding:4px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;box-shadow:0 12px 32px #00000080}.agent-menu-item{display:flex;align-items:center;gap:9px;width:100%;background:transparent;border:none;color:var(--text);font-family:inherit;font-size:12px;text-align:left;padding:6px 8px;border-radius:5px;cursor:pointer}.agent-menu-item:hover:not(:disabled){background:var(--bg3)}.agent-menu-item:disabled{opacity:.4;cursor:default}.agent-menu-item.danger{color:var(--danger)}.agent-menu-item.danger:hover:not(:disabled){background:#f0726a1f}.agent-menu-icon{width:16px;text-align:center;flex-shrink:0}.agent-menu-label{flex:1}.agent-menu-check{color:var(--accent);font-size:11px}.agent-menu-sep{height:1px;background:var(--border);margin:4px 6px}.roster-status{font-size:10px;color:var(--muted);text-transform:lowercase}.retry-info{color:#f59e0b;font-size:10px}.roster-model{display:inline-block;padding:1px 4px;border-radius:3px;background:var(--bg3);border:1px solid var(--border);color:var(--muted);font-size:9px;text-transform:none;white-space:nowrap}.roster-model.cloud{color:#e6c07b;border-color:#5a4f2c}.ctx-bar{display:flex;align-items:center;gap:6px;margin-top:6px}.ctx-fill-wrap{flex:1;height:4px;border-radius:2px;background:var(--border);overflow:hidden}.ctx-fill{height:100%;border-radius:2px;transition:width .3s ease,background .3s ease}.ctx-green{background:#5dcaa5}.ctx-yellow{background:#e6c07b}.ctx-orange{background:#e08a5e}.ctx-red{background:#f0726a}.ctx-label{flex-shrink:0;font-size:10px;color:var(--muted)}.session-stats{font-size:10px;color:var(--muted);opacity:.8}.ctx-warning .ctx-fill{animation:ctx-pulse 1.5s ease-in-out infinite}@keyframes ctx-pulse{0%,to{opacity:1}50%{opacity:.5}}.ca-model-label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--muted)}.ca-model{width:100%}.ca-model-hint{font-size:10px;color:var(--muted);opacity:.8}.status-active,.status-working{color:var(--accent)}.status-working:before{content:"● "}.roster-actions{display:flex;gap:3px}.mini{background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:5px;width:22px;height:22px;cursor:pointer;font-size:13px}.mini:hover{color:var(--text);border-color:var(--muted)}.mini.danger:hover{color:var(--danger);border-color:var(--danger)}.mini.star-on{color:#e6c07b;border-color:#5a4f2c}.mini.par-on{color:var(--accent);border-color:var(--accent);font-weight:700}.mini:disabled{opacity:.35;cursor:default}.roster-foot{padding:10px;border-top:1px solid var(--border)}.center{display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--bg)}.topbar{height:42px;display:flex;align-items:center;gap:12px;padding:0 16px;border-bottom:1px solid var(--border)}.brand{font-weight:700}.topbar-sub{color:var(--muted);font-size:11px}.chain-toggle{margin-left:auto;background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:7px;padding:5px 10px;font:inherit;font-size:11px;cursor:pointer}.chain-toggle.on{color:var(--accent);border-color:var(--accent)}.topbar-routing{margin-left:auto;display:flex;flex-direction:row-reverse;align-items:center;gap:8px}.mode-select{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden}.mode-opt{background:transparent;border:none;color:var(--muted);font:inherit;font-size:11px;padding:5px 10px;cursor:pointer;text-transform:capitalize}.mode-opt:not(:last-child){border-right:1px solid var(--border)}.mode-opt.on{background:var(--accent);color:#06281f;font-weight:600}.mode-opt:hover:not(.on){color:var(--text)}.route-approval{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 12px 8px;padding:9px 12px;background:var(--bg2);border:1px solid var(--accent);border-left-width:3px;border-radius:9px;font-size:12.5px}.route-label{font-weight:600;color:var(--text)}.route-hops{display:flex;flex-wrap:wrap;gap:4px 12px;color:var(--muted)}.route-hop b{color:var(--accent);font-weight:600}.route-actions{display:flex;gap:6px;margin-left:auto}.route-select{background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:4px 8px;font:inherit;font-size:12px}.route-btn{background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);font:inherit;font-size:12px;padding:4px 10px;cursor:pointer}.route-btn:hover:not(:disabled){border-color:var(--accent)}.route-btn:disabled{opacity:.5;cursor:default}.route-btn.approve{background:var(--accent);color:#06281f;border-color:var(--accent);font-weight:600}.route-btn.drop{color:#e0707a}.route-btn.ghost{color:var(--muted)}.chain-hops{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:4px}.chain-hops input{width:48px;background:var(--bg);border:1px solid var(--border);color:var(--fg);border-radius:4px;padding:3px 6px;font:inherit;font-size:11px}.hops-apply{background:var(--accent);color:#06281f;border:none;border-radius:4px;width:20px;height:20px;line-height:1;font-size:11px;font-weight:700;cursor:pointer;padding:0;flex-shrink:0}.hops-apply:hover{filter:brightness(1.1)}.conv-bar{position:relative}.conv-trigger{display:flex;align-items:center;gap:6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:5px 9px;font:inherit;font-size:12px;cursor:pointer;max-width:240px}.conv-trigger:hover{border-color:var(--muted)}.conv-trigger-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conv-caret{color:var(--muted);font-size:10px}.conv-menu{position:absolute;top:100%;left:0;margin-top:6px;width:280px;background:var(--bg3);border:1px solid var(--border);border-radius:9px;box-shadow:0 8px 24px #00000080;z-index:60;overflow:hidden}.conv-list{max-height:320px;overflow:auto;padding:6px}.conv-empty{color:var(--muted);padding:10px;font-size:12px}.conv-item{display:flex;align-items:center;gap:4px;border-radius:6px;padding:2px 4px}.conv-item:hover{background:var(--bg)}.conv-item.active{background:var(--bg2)}.conv-item.active .conv-name-text{color:var(--accent)}.conv-name{flex:1;min-width:0;display:flex;align-items:center;gap:8px;background:transparent;border:none;color:var(--text);font:inherit;font-size:12px;text-align:left;padding:6px;cursor:pointer}.conv-name:disabled{opacity:.4;cursor:default}.conv-name-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conv-count{color:var(--muted);font-size:10px;flex-shrink:0}.conv-edit{flex:1;min-width:0;background:var(--bg);border:1px solid var(--accent);color:var(--text);border-radius:5px;padding:5px 6px;font:inherit;font-size:12px}.conv-item-actions{display:flex;gap:2px;flex-shrink:0}.conv-new{width:100%;background:transparent;border:none;border-top:1px solid var(--border);color:var(--accent);font:inherit;font-size:12px;font-weight:600;padding:9px;cursor:pointer;text-align:left}.conv-new:hover:not(:disabled){background:var(--bg)}.conv-new:disabled{opacity:.4;cursor:default}.preset-bar{position:relative}.preset-trigger{display:flex;align-items:center;gap:4px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 10px;color:var(--text);font:inherit;font-size:12px;cursor:pointer}.preset-trigger:hover{border-color:var(--muted)}.preset-caret{color:var(--muted);font-size:10px}.preset-menu{position:absolute;top:100%;right:0;margin-top:6px;width:280px;background:var(--bg2);border:1px solid var(--border);border-radius:10px;box-shadow:0 12px 32px #00000080;z-index:100;overflow:hidden}.preset-section{padding:8px}.preset-section+.preset-section{border-top:1px solid var(--border)}.preset-label{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.preset-save-row{display:flex;gap:6px}.preset-input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:5px 8px;color:var(--text);font:inherit;font-size:12px}.preset-input:focus{outline:none;border-color:var(--accent)}.preset-save-btn{background:var(--accent);border:none;border-radius:5px;padding:5px 12px;color:var(--bg);font:inherit;font-size:12px;font-weight:600;cursor:pointer}.preset-save-btn:disabled{opacity:.4;cursor:default}.preset-empty{color:var(--muted);padding:10px;font-size:12px}.preset-item{display:flex;align-items:center;gap:4px;border-radius:6px;padding:4px 6px}.preset-item:hover{background:var(--bg)}.preset-item-info{flex:1;min-width:0;display:flex;align-items:center;gap:6px}.preset-name{font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.preset-agent-count{color:var(--muted);font-size:11px}.preset-cloud{font-size:12px;color:var(--muted)}.preset-item-actions{display:flex;gap:2px;flex-shrink:0}.preset-loading{color:var(--muted);font-size:11px;padding:4px 6px}.transcript{flex:1;overflow:auto;padding:18px 20px;display:flex;flex-direction:column;gap:16px}.row{display:flex;flex-direction:column;max-width:760px}.row.user{align-self:flex-end;align-items:flex-end}.row.agent{align-self:flex-start}.image-gallery{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}.image-thumb{max-width:200px;max-height:180px;border-radius:8px;border:1px solid var(--border);object-fit:contain;cursor:pointer;transition:transform .15s}.image-thumb:hover{transform:scale(1.03)}.agent-head{display:flex;align-items:center;gap:6px;font-weight:700;font-size:12px;margin-bottom:5px}.bubble{border-radius:10px;padding:11px 14px;white-space:pre-wrap;word-break:break-word;line-height:1.5}.bubble-user{background:var(--bg3);border:1px solid var(--border)}.bubble-agent{background:var(--bg2);border-left:3px solid var(--accent)}.bubble-agent.streaming{opacity:.92}.caret{display:inline-block;width:7px;height:14px;margin-left:2px;background:currentColor;opacity:.6;vertical-align:text-bottom;animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.receipt{margin-top:8px}.receipt-label{color:var(--muted);font-size:10px;letter-spacing:.06em;margin-bottom:5px}.receipt-chips{display:flex;flex-wrap:wrap;gap:6px}.chip-file{font-size:11px;padding:3px 7px;border-radius:6px;border:1px solid var(--border)}.kind-add{color:var(--accent);border-color:#2c5a48}.kind-mod{color:#e6c07b;border-color:#5a4f2c}.kind-del{color:var(--danger);border-color:#5a2c2c}.composer{position:relative;border-top:1px solid var(--border);padding:12px 16px;background:var(--bg2)}.composer-row{display:flex;gap:10px;align-items:flex-end}.composer-input{flex:1;resize:none;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:10px;padding:11px 13px;font:inherit;max-height:160px;min-height:84px}.composer-input:focus{outline:none;border-color:var(--accent)}.image-preview-strip{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}.image-preview-thumb{position:relative;width:64px;height:64px;border-radius:6px;overflow:hidden;border:1px solid var(--border)}.image-preview-thumb img{width:100%;height:100%;object-fit:cover}.image-preview-remove{position:absolute;top:2px;right:2px;width:18px;height:18px;border:none;border-radius:50%;background:#000000bf;color:var(--danger);font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}.image-preview-remove:hover{background:var(--danger);color:#fff}.ask-banner{display:flex;align-items:flex-start;gap:10px;padding:10px 14px;background:#1a2535;border:1px solid #3b82f6;border-radius:6px;margin-bottom:8px;animation:askBannerIn .2s ease-out}.ask-banner-icon{font-size:18px;flex-shrink:0;margin-top:1px}.ask-banner-body{display:flex;flex-direction:column;gap:2px}.ask-banner-who{font-size:11px;font-weight:600;color:#60a5fa;text-transform:uppercase;letter-spacing:.04em}.ask-banner-text{font-size:13px;color:#e2e8f0;font-style:italic;line-height:1.4}@keyframes askBannerIn{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.ask-callout{display:flex;align-items:flex-start;gap:8px;padding:8px 12px;background:#111827;border-left:3px solid #3b82f6;border-radius:4px;margin-top:6px;font-size:13px;color:#93c5fd;font-style:italic}.ask-callout-icon{flex-shrink:0;font-style:normal}.ask-callout-text{line-height:1.4}.steer-flash{position:absolute;bottom:100%;left:0;right:0;padding:4px 16px;background:#d97706;color:#1c0f00;font-size:11px;text-align:center;animation:steerFadeIn .2s ease-out;pointer-events:none}@keyframes steerFadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.mention-pop{position:absolute;bottom:100%;left:16px;margin-bottom:6px;background:var(--bg3);border:1px solid var(--border);border-radius:9px;min-width:220px;overflow:hidden;box-shadow:0 8px 24px #00000080}.mention-item{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}.mention-item.active,.mention-item:hover{background:var(--bg)}.mention-hint{margin-left:auto;color:var(--muted);font-size:10px}.btn{border:none;border-radius:9px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}.btn-send{background:var(--accent);color:#06281f}.btn-stop{background:var(--danger);color:#2a0a08}.btn-steer{background:#d97706;color:#1c0f00}.btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--border)}.btn-ghost:hover{color:var(--text)}.btn.full{width:100%}.btn:disabled{opacity:.5;cursor:default}.create-agent{display:flex;flex-direction:column;gap:8px}.ca-input{background:var(--bg3);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:7px 9px;font:inherit}.ca-row{display:flex;gap:8px}.ca-icon{width:48px;text-align:center}.ca-color{flex:1;padding:2px;height:32px}.ca-tools{display:flex;flex-wrap:wrap;gap:5px}.chip{font-size:11px;padding:3px 7px;border-radius:6px;border:1px solid var(--border);color:var(--muted);cursor:pointer}.chip.on{color:var(--accent);border-color:var(--accent)}.chip input{display:none}.ca-actions{display:flex;gap:8px;justify-content:flex-end}.add-agent{display:flex;flex-direction:column;gap:8px}.add-agent-head{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.add-agent-list{display:flex;flex-direction:column;gap:5px;max-height:320px;overflow-y:auto}.add-agent-empty{color:var(--muted);font-size:12px;padding:6px 2px}.add-agent-item{display:flex;align-items:center;gap:9px;width:100%;text-align:left;background:var(--bg3);border:1px solid var(--border);border-radius:7px;padding:7px 9px;cursor:pointer;font-family:inherit;transition:border-color .15s,background .15s}.add-agent-item:hover:not(:disabled){border-color:var(--accent);background:var(--bg2)}.add-agent-item:disabled{opacity:.5;cursor:default}.aa-icon{width:26px;height:26px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}.aa-body{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}.aa-name{font-size:12.5px;font-weight:600;color:var(--text)}.aa-meta{font-size:10.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.aa-add{color:var(--accent);font-size:15px;font-weight:700;flex-shrink:0}.add-agent-custom{background:transparent;border:1px dashed var(--border);border-radius:7px;color:var(--muted);font-family:inherit;font-size:12px;padding:7px;cursor:pointer}.add-agent-custom:hover{color:var(--accent);border-color:var(--accent)}.workspace{background:var(--bg2);border-left:1px solid var(--border);display:flex;flex-direction:column;min-height:0}.workspace-head{padding:14px;border-bottom:1px solid var(--border)}.workspace-title{color:var(--accent);font-weight:700;letter-spacing:.08em;font-size:12px}.workspace-sub{color:var(--muted);font-size:11px;margin-top:4px}.workspace-list{flex:1;overflow:auto;padding:8px}.workspace-empty{color:var(--muted);padding:10px}.workspace-file{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:6px}.workspace-file:hover{background:var(--bg3)}.wf-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.wf-size{color:var(--muted);font-size:11px;flex-shrink:0}.side-tabs{display:flex;gap:4px}.side-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--muted);font-family:inherit;font-weight:700;letter-spacing:.08em;font-size:12px;text-transform:uppercase;cursor:pointer;padding:2px 6px 4px}.side-tab:hover{color:var(--text)}.side-tab.active{color:var(--accent);border-bottom-color:var(--accent)}.presets-panel{display:flex;flex-direction:column;gap:6px}.presets-error{color:var(--danger);font-size:11px;padding:8px 10px}.presets-warn{color:#e0a23a;font-size:11px;padding:6px 10px;line-height:1.4}.preset-card-warn,.pm-warn{color:#e0a23a;font-weight:700;font-size:11px}.preset-card-warn{margin-left:2px}.pm-warn{margin-left:4px}.preset-card{border:1px solid var(--border);border-radius:6px;background:var(--bg3);overflow:hidden}.preset-card.open{border-color:var(--accent)}.preset-card-head{display:flex;align-items:center;gap:8px;width:100%;background:transparent;border:none;cursor:pointer;font-family:inherit;padding:8px 10px;color:var(--text)}.preset-card-head:hover{background:var(--bg2)}.preset-card-caret{color:var(--muted);font-size:10px;width:10px}.preset-card-name{font-size:12.5px;font-weight:600;flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.preset-card-count{color:var(--muted);font-size:11px;background:var(--bg);border-radius:10px;padding:1px 7px}.preset-card-cloud{font-size:11px}.preset-members{display:flex;flex-direction:column;gap:4px;padding:4px 8px 8px;border-top:1px solid var(--border)}.preset-member{display:flex;gap:8px;padding:6px 4px;border-radius:5px}.preset-member:hover{background:var(--bg2)}.preset-member.inactive{opacity:.5}.pm-icon{font-size:15px;line-height:1.4;flex-shrink:0}.pm-body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.pm-line1{display:flex;align-items:center;gap:6px}.pm-name{font-size:12px;font-weight:600;color:var(--text)}.pm-badge{font-size:10px;color:var(--muted);border:1px solid var(--border);border-radius:4px;padding:0 4px}.pm-badge.off{color:var(--danger);border-color:var(--danger)}.pm-line2{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pm-model{color:var(--text)}.pm-think{margin-left:4px}.pm-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:3px}.pm-tool{font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:0 5px;line-height:1.5}.preset-card-actions{display:flex;gap:6px;padding:6px 4px 2px;border-top:1px dashed var(--border);margin-top:2px}.preset-card-actions .mini{font-size:11px;padding:3px 8px;width:auto}.notices{position:fixed;bottom:16px;left:50%;transform:translate(-50%);display:flex;flex-direction:column;gap:8px;z-index:50}.notice{padding:9px 14px;border-radius:8px;font-size:12px;border:1px solid var(--border);background:var(--bg3)}.notice-error{border-color:var(--danger);color:var(--danger)}.activity{margin:6px 0;border:1px solid var(--border);border-radius:8px;background:var(--bg2);font-size:12px}.activity-summary{cursor:pointer;padding:5px 9px;color:var(--muted);letter-spacing:.02em;-webkit-user-select:none;user-select:none}.activity-summary:hover{color:var(--text)}.activity-list{padding:4px 9px 8px;display:flex;flex-direction:column;gap:6px}.activity-item{border-left:2px solid var(--border);padding-left:8px}.activity-item.status-error{border-left-color:var(--danger)}.activity-item.status-ok{border-left-color:#2c5a48}.activity-item.status-running{border-left-color:#e6c07b}.activity-line{display:flex;align-items:center;gap:8px}.activity-tool{color:var(--text);font-weight:600;white-space:nowrap}.activity-icon{margin-right:3px}.activity-args{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.activity-badge{font-size:10px;padding:1px 6px;border-radius:5px;border:1px solid var(--border);color:var(--muted)}.badge-ok{color:var(--accent);border-color:#2c5a48}.badge-error{color:var(--danger);border-color:#5a2c2c}.badge-running{color:#e6c07b;border-color:#5a4f2c}.activity-result{margin-top:4px}.activity-result>summary{cursor:pointer;color:var(--muted);font-size:11px}.activity-result pre{margin:4px 0 0;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px;color:var(--text)}.reasoning-live{margin:6px 0;font-size:12px}.reasoning-live>summary{cursor:pointer;color:var(--muted);font-style:italic}.reasoning-live pre{margin:4px 0 0;padding:6px 8px;background:var(--bg2);border:1px dashed var(--border);border-radius:6px;max-height:180px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px;color:var(--muted)}.reasoning-done{margin:6px 0;font-size:12px}.reasoning-done>summary{cursor:pointer;color:var(--muted);font-style:italic}.reasoning-done pre{margin:4px 0 0;padding:6px 8px;background:var(--bg2);border:1px dashed var(--border);border-radius:6px;max-height:180px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px;color:var(--muted)}.roster-item.editing{background:var(--bg3)}.ca-editing{color:var(--accent);font-size:11px;letter-spacing:.04em;margin-bottom:2px}.providers-panel{border-top:1px solid var(--border);padding:10px 12px;font-size:12px}.providers-panel h3{margin:0 0 8px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}.provider-row{display:flex;align-items:center;justify-content:space-between;padding:4px 0;gap:8px}.provider-row.local{color:var(--muted)}.provider-name{font-weight:600;font-size:12px}.provider-info{display:flex;align-items:center;gap:6px;flex:1;min-width:0}.provider-badge{font-size:9px;background:var(--bg3);color:var(--muted);padding:1px 5px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}.provider-models{font-size:10px;color:var(--muted)}.provider-status{font-size:11px}.provider-status.configured{color:var(--accent)}.provider-actions{display:flex;align-items:center;gap:4px}.confirm-remove{display:flex;gap:4px}.add-key-row{padding:4px 0}.api-key-input{flex:1;min-width:0;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:5px 10px;color:var(--text);font-size:12px;font-family:inherit;outline:none}.api-key-input:focus{border-color:var(--accent)}.api-key-input::placeholder{color:var(--muted)}.btn-small{border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;background:var(--bg3);color:var(--text);font-family:inherit;white-space:nowrap}.btn-small:hover{background:var(--border)}.btn-small.primary{background:var(--accent);color:#06281f}.btn-small.danger{color:var(--danger)}.btn-small.danger:hover{background:#f0726a1a}.add-provider-toggle{width:100%;background:transparent;border:none;color:var(--muted);font-size:11px;font-family:inherit;cursor:pointer;padding:4px 0;text-align:left}.add-provider-toggle:hover{color:var(--text)}.unconfigured-list{margin-top:2px;max-height:40vh;overflow-y:auto}.room-tabs{display:flex;align-items:center;gap:2px;padding:4px 8px;border-bottom:1px solid var(--border);background:var(--bg2);min-height:34px;overflow-x:auto;flex-shrink:0}.room-tab{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);font-family:inherit;font-size:12px;cursor:pointer;white-space:nowrap;transition:color .15s,background .15s}.room-tab:hover{color:var(--text);background:var(--bg3)}.room-tab.active{color:var(--text);background:var(--bg3);border-color:var(--border)}.room-tab-name{max-width:120px;overflow:hidden;text-overflow:ellipsis}.room-tab-status{font-size:10px;color:var(--muted)}.room-tab-close{font-size:14px;line-height:1;color:var(--muted);cursor:pointer;border-radius:3px;padding:0 2px;margin-left:2px}.room-tab-close:hover{color:var(--danger);background:#f0726a1f}.room-tab-stop{font-size:11px;line-height:1;color:var(--muted);cursor:pointer;border-radius:3px;padding:1px 3px;margin-left:2px}.room-tab-stop:hover{color:#f0b86e;background:#f0b86e24}.room-tab-new{padding:3px 8px;border-radius:6px;border:1px dashed var(--border);background:transparent;color:var(--muted);font-family:inherit;font-size:11px;cursor:pointer;margin-left:4px;white-space:nowrap}.room-tab-new:hover{color:var(--accent);border-color:var(--accent)}.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--muted)}.status-dot.running{background:var(--accent);animation:pulse-dot 1.5s ease-in-out infinite}.status-dot.completed{background:#5b8af5}.status-dot.failed{background:var(--danger)}.status-dot.cancelled{background:#f0b86e}@keyframes pulse-dot{0%,to{opacity:1}50%{opacity:.35}}.dialog-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#0000008c;display:flex;align-items:center;justify-content:center;z-index:1000}.create-room-dialog{background:var(--bg2);border:1px solid var(--border);border-radius:10px;width:440px;max-width:90vw;box-shadow:0 20px 60px #00000080}.dialog-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--border)}.dialog-title{font-size:13px;font-weight:700;color:var(--accent);letter-spacing:.06em;text-transform:uppercase}.dialog-tabs{display:flex;gap:4px}.dialog-tab{background:transparent;border:none;color:var(--muted);font-family:inherit;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;padding:4px 8px;border-radius:5px;border-bottom:2px solid transparent}.dialog-tab:hover{color:var(--text);background:var(--bg3)}.dialog-tab.active{color:var(--accent);border-bottom-color:var(--accent)}.resume-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;max-height:320px;overflow-y:auto}.resume-item{display:flex;flex-direction:column;gap:3px;width:100%;text-align:left;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;cursor:pointer;font-family:inherit;transition:border-color .15s,background .15s}.resume-item:hover:not(:disabled){border-color:var(--accent);background:var(--bg2)}.resume-item:disabled{opacity:.5;cursor:default}.resume-name{font-size:12.5px;font-weight:600;color:var(--text)}.resume-meta{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dialog-close{background:transparent;border:none;color:var(--muted);font-size:18px;cursor:pointer;line-height:1;padding:2px 4px;border-radius:4px}.dialog-close:hover{color:var(--text);background:var(--bg3)}.dialog-body{padding:16px;display:flex;flex-direction:column;gap:12px}.dialog-field{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--muted)}.dialog-input{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-family:inherit;font-size:12px;outline:none;transition:border-color .15s}.dialog-input:focus{border-color:var(--accent)}.dialog-hint{font-size:11px;line-height:1.4}.dialog-hint code{font-family:var(--mono, monospace);font-size:10.5px;background:var(--bg3);padding:1px 4px;border-radius:3px}.dialog-textarea{resize:vertical;min-height:60px}.dialog-error{color:var(--danger);font-size:12px}.dialog-actions{display:flex;gap:8px;justify-content:flex-end;padding-top:4px}.btn-primary{background:var(--accent);color:#06281f;border:none;border-radius:6px;padding:7px 16px;font-family:inherit;font-size:12px;font-weight:700;cursor:pointer}.btn-primary:hover:not(:disabled){filter:brightness(1.1)}.btn-primary:disabled{opacity:.5;cursor:not-allowed}.btn-secondary{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:7px 14px;font-family:inherit;font-size:12px;cursor:pointer}.btn-secondary:hover:not(:disabled){background:var(--border)}.btn-secondary:disabled{opacity:.5;cursor:not-allowed}.muted{color:var(--muted)}.settings-panel{display:flex;flex-direction:column;gap:14px;padding:10px}.settings-section{display:flex;flex-direction:column;gap:10px}.settings-section-title{font-size:11px;font-weight:600;text-transform:uppercase;color:var(--muted);margin:0;padding:0}.settings-field{display:flex;flex-direction:column;gap:4px}.settings-label{font-size:13px;color:var(--text)}.settings-select,.settings-input{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font-family:inherit;font-size:13px}.settings-select:disabled,.settings-input:disabled{opacity:.5}.settings-hint{font-size:11px;color:var(--muted);line-height:1.3}.settings-value{font-size:13px;color:var(--text);font-variant-numeric:tabular-nums}.toggle{position:relative;display:inline-flex;align-items:center;width:36px;height:20px;cursor:pointer}.toggle input{position:absolute;opacity:0;width:0;height:0}.toggle-track{position:absolute;top:0;right:0;bottom:0;left:0;background:var(--border);border-radius:10px;transition:background .15s}.toggle input:checked+.toggle-track{background:var(--accent)}.toggle input:disabled+.toggle-track{opacity:.5;cursor:not-allowed}.toggle-knob{position:absolute;left:3px;top:3px;width:14px;height:14px;background:var(--text);border-radius:50%;transition:left .15s}.toggle input:checked~.toggle-knob{left:19px}.settings-section:not(:last-child){border-bottom:1px solid var(--border);padding-bottom:14px}.settings-section:last-child{padding-bottom:0}.settings-select,.settings-input{box-sizing:border-box}
|