Official

chat

Built-in chat UI (JavaScript UI plugin).

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/chat@0.2.0

Permissions in 0.2.0

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

Files

events.ts8 KB
// Event listeners update only the affected chat pane, matching the streaming baseline.
// Per-chat FIFO releases only on host settlement. run_finished is transcript
// progress, never evidence that required persistence/checkpoints have finished.
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { Events } from "convergence/effect";
import type { HostEvents } from "convergence";
import type { Controller } from "./controller.ts";
import { applyEvent } from "./transcript.ts";
import { isLive } from "./subagents.ts";

export const onAgent = Effect.fnUntraced(function* (c: Controller, event: HostEvents["agent"]) {
  const composer = c.state.composers[event.chatId],
    kind = event.kind;
  if (composer) {
    if (kind.event === "config_options") {
      composer.options = kind.options;
      c.view.update();
    } else if (kind.event === "commands") {
      composer.commands = kind.commands;
      c.view.update();
    } else if (kind.event === "skills") {
      composer.skills = kind.skills;
      c.view.update();
    }
  }
  if (kind.event === "run_finished") c.view.update();
  if (kind.event === "usage_blocked" && !event.taskId) {
    c.scheduler.activeLimits[event.chatId] = { runId: c.scheduler.snapshots[event.chatId]?.runId, evidence: kind.recovery };
    c.refresh(event.chatId);
  }
  const transcript = c.state.transcripts[event.chatId];
  if (transcript && kind.event === "usage" && !event.taskId) {
    transcript.usage = kind;
    if (c.api.app.state().chatId === event.chatId) c.titleView?.update();
    return;
  }
  if (!transcript || !applyEvent(transcript, kind, event.taskId)) return;
  if (event.taskId || kind.event === "task" || kind.event === "approval_resolved") {
    c.refreshSubagentTabs(event.chatId);
    if (kind.event === "task") {
      c.titleView?.update();
      if (isLive(kind)) yield* Effect.forkIn(c.tickSubagents(), c.scope, { startImmediately: true });
    }
  }
  const chat = c.state.chats[event.chatId],
    pane = chat ? c.paneOf(chat) : null;
  if (pane) pane.view.update();
  else if (!pane && c.api.app.state().chatId === event.chatId) c.center?.update();
});
export const onChatChanged = Effect.fn("Chat.onChatChanged")(function* (
  c: Controller,
  event: HostEvents["chat_changed"],
) {
  const before = c.state.chats[event.chat.id];
  c.state.chats[event.chat.id] = event.chat;
  if (event.chat.archived && !before?.archived) c.removePane(event.chat.id);
  const moved = before && (before.agentId !== event.chat.agentId || before.workspaceId !== event.chat.workspaceId);
  if (moved && c.state.composers[event.chat.id])
    yield* Effect.forkIn(c.loadOptions(event.chat.id), c.scope, { startImmediately: true });
  if (c.scheduler.ready && (!c.scheduler.snapshots[event.chat.id] || moved))
    yield* Effect.forkIn(c.scheduler.load(event.chat.id), c.scope, { startImmediately: true });
  yield* c.syncDraftTitle(event.chat.id);
  // A queued draft starts once no chat runs; the one just sent holds the
  // queue until the host reports it running.
  if (c.state.releasing === event.chat.id && (c.isRunning(event.chat) || event.chat.sessionId || event.chat.archived))
    c.state.releasing = null;
  if (event.chat.archived) yield* c.unqueueChat(event.chat.id);
  yield* Effect.forkIn(c.pumpChatQueue(), c.scope, { startImmediately: true });
  // Stream consumers run after the slot listener; repaint with the updated chat metadata.
  c.titleView?.update();
  c.view.update();
});
export const onAgentsChanged = Effect.fn("Chat.onAgentsChanged")(function* (c: Controller) {
  yield* Effect.forkIn(c.loadChat(c.api.app.state().chatId), c.scope, {
    startImmediately: true,
  });
  for (const [chatId, composer] of Object.entries(c.state.composers))
    if (!composer.options.length && !composer.loading)
      yield* Effect.forkIn(c.loadOptions(chatId), c.scope, { startImmediately: true });
});
export const onChatRemoved = Effect.fn("Chat.onChatRemoved")(function* (
  c: Controller,
  event: HostEvents["chat_removed"],
) {
  delete c.state.transcripts[event.chatId];
  delete c.state.composers[event.chatId];
  if (c.state.restored) delete c.state.restored[event.chatId];
  yield* Effect.forkIn(c.saveUnsent(), c.scope, { startImmediately: true });
  delete c.state.chats[event.chatId];
  if (c.state.releasing === event.chatId) c.state.releasing = null;
  yield* c.unqueueChat(event.chatId);
  yield* Effect.forkIn(c.pumpChatQueue(), c.scope, { startImmediately: true });
  delete c.scheduler.snapshots[event.chatId];
  delete c.scheduler.activeLimits[event.chatId];
  c.removePane(event.chatId);
});

// All streams and background listeners end with the owning plugin scope.
export const listen = Effect.fn("Chat.listen")(function* (c: Controller) {
  const events = yield* Events;
  const fork = <A, E>(effect: Effect.Effect<A, E>) => Effect.forkIn(effect, c.scope, { startImmediately: true });
  yield* events.stream("inputs_changed").pipe(
    Stream.runForEach((event) => {
      c.scheduler.receive(event.snapshot);
      return fork(c.scheduler.evaluate());
    }),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("run_settled").pipe(
    Stream.runForEach((event) => fork(c.scheduler.load(event.chatId).pipe(Effect.andThen(c.scheduler.evaluate())))),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("workspaces_changed").pipe(
    Stream.runForEach(() =>
      fork(
        c.loadWorkspaces().pipe(Effect.catch((error) => Effect.logWarning(`workspaces unavailable: ${error.message}`))),
      ),
    ),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("chat_changed").pipe(
    Stream.runForEach((event) => fork(onChatChanged(c, event))),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("agents_changed").pipe(
    Stream.runForEach(() => fork(onAgentsChanged(c))),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("agent").pipe(
    Stream.runForEach((event) => onAgent(c, event)),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("user_message").pipe(
    Stream.runForEach((event) =>
      fork(
        Effect.gen(function* () {
          const transcript = c.state.transcripts[event.chatId];
          if (!transcript) return;
          transcript.items.push(event.item);
          transcript.running = true;
          transcript.checkpoints.add(event.item.id);
          c.view.update();
          if (c.api.app.state().chatId === event.chatId) yield* c.loadImages(event.chatId);
        }),
      ),
    ),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("transcript_reset").pipe(
    Stream.runForEach((event) =>
      fork(
        Effect.gen(function* () {
          delete c.state.transcripts[event.chatId];
          c.view.update();
          if (c.api.app.state().chatId === event.chatId) yield* c.ensureChat(event.chatId);
        }),
      ),
    ),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("security_request").pipe(
    Stream.runForEach(({ request }) =>
      Effect.sync(() => {
        const transcript = request.chatId ? c.state.transcripts[request.chatId] : null;
        if (!transcript) return;
        transcript.requests = [...transcript.requests.filter((held) => held.id !== request.id), request];
        c.refresh(request.chatId);
      }),
    ),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("security_resolved").pipe(
    Stream.runForEach(({ id }) =>
      Effect.sync(() => {
        for (const [chatId, transcript] of Object.entries(c.state.transcripts)) {
          if (!transcript.requests.some((request) => request.id === id)) continue;
          transcript.requests = transcript.requests.filter((request) => request.id !== id);
          c.refresh(chatId);
        }
      }),
    ),
    Effect.forkScoped({ startImmediately: true }),
  );
  yield* events.stream("chat_removed").pipe(
    Stream.runForEach((event) => fork(onChatRemoved(c, event))),
    Effect.forkScoped({ startImmediately: true }),
  );
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.