Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ codeoid start
│ ├── TranscriptStore — JSONL persistence, survives daemon restart
│ └── AgentIdentityManager — ZeroID identities for agents + sub-agents
├── Web UI frontend (SolidJS, served at /ui, also Telegram Mini App)
└── Telegram frontend (embedded, direct SessionManager access)
├── Telegram frontend (embedded, direct SessionManager access)
└── Slack frontend (embedded, Socket Mode: src/frontends/slack/, docs/slack.md)
```

### Plugin Architecture
Expand Down Expand Up @@ -74,6 +75,7 @@ src/
│ ├── types.ts # Frontend plugin interface
│ ├── index.ts
│ ├── telegram/index.ts # Grammy bot, embedded in daemon process
│ ├── slack/ # Slack app over Socket Mode (socket.ts) and the frontend (index.ts), docs/slack.md
│ └── web-ui/index.ts # Serves the built SolidJS app (web/dist) at /ui, also Telegram Mini App
└── terminal/
└── client.ts # WebSocket client, connects to daemon
Expand Down Expand Up @@ -604,6 +606,7 @@ ZEROID_URL=highflame # issuer: preset (highflame | highflame-dev
ZEROID_ISSUER= # override the expected `iss` claim (default: resolved ZEROID_URL)
TELEGRAM_BOT_TOKEN=... # Optional: enables Telegram frontend
TELEGRAM_ALLOWED_USER_IDS=123,456 # Required with bot token
CODEOID_SLACK_APP_TOKEN=xapp-... # Optional, with CODEOID_SLACK_BOT_TOKEN and CODEOID_SLACK_ALLOWED_USER_IDS: the Slack frontend (docs/slack.md)
CODEOID_MCP_CONTROL_ENABLED=false # mount /mcp/control (docs/mcp-control.md)
CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt / destroy there
CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routines.md)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ Full detail — keybindings, slash commands, ranking weights, rotation threshold

### Interfaces

One daemon, one source of truth, three ways in: the [terminal cockpit](docs/FEATURES.md#terminal-client) (shown at the top), a browser, and Telegram.
One daemon, one source of truth, four ways in: the [terminal cockpit](docs/FEATURES.md#terminal-client) (shown at the top), a browser, Telegram, and [Slack](docs/slack.md).
[Device handoff](docs/FEATURES.md#device-handoff) lets you start on one and pick the session up on another — scrollback replays.

<p align="center">
Expand Down
68 changes: 68 additions & 0 deletions docs/slack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Slack

Talk to codeoid's conductor from a Slack DM, and approve what your sessions ask for from there.

The daemon connects to Slack itself (Socket Mode), so it needs no public URL.
Slack works only with ZeroID sign-in, never in local mode.

## Setting it up

1. Create a Slack app in a workspace you own: <https://api.slack.com/apps> → Create New App → From an app manifest, and paste:

```yaml
display_information:
name: codeoid
features:
bot_user:
display_name: codeoid
always_online: true
app_home:
messages_tab_enabled: true
messages_tab_read_only_enabled: false
slash_commands:
- command: /codeoid
description: Sign in, list and switch sessions
usage_hint: auth <key> | ls | attach <name> | conductor | stop | who
oauth_config:
scopes:
bot:
- chat:write
- commands
- im:history
- im:write
settings:
event_subscriptions:
bot_events:
- message.im
interactivity:
is_enabled: true
socket_mode_enabled: true
```

2. Install it to the workspace, and copy the **Bot User OAuth Token** (`xoxb-…`).
3. Under Basic Information → App-Level Tokens, create one with the `connections:write` scope, and copy it (`xapp-…`).
4. Find your Slack member ID: your profile → ⋯ → Copy member ID.
5. Add to `~/.codeoid/.env`, then restart the daemon:

```bash
CODEOID_SLACK_APP_TOKEN=xapp-…
CODEOID_SLACK_BOT_TOKEN=xoxb-…
CODEOID_SLACK_ALLOWED_USER_IDS=U0123ABCD # comma-separated
CODEOID_SLACK_TEAM_ID=T0123ABCD # optional: only this workspace
```

6. In Slack, run `/codeoid auth <zid_sk_…>` once with your ZeroID key. A slash command isn't posted to the conversation, so the key doesn't stay in it.

`codeoid start --no-slack` starts the daemon without it.

## Using it

- Message the codeoid app to talk to the conductor. It is created the first time you write.
- `/codeoid ls` lists your sessions; `/codeoid attach <name>` talks to one instead; `/codeoid conductor` goes back.
- `/codeoid stop` interrupts the current turn; `/codeoid who` shows who you are signed in as.
- When the session you're talking to asks to run something, a card shows what it would do, the workstream it acts in, its risk and whether it is sandboxed, with Approve and Deny buttons. Only the person a card was sent to can press it, and only once; a card answered elsewhere (the web UI, the CLI) closes.
- When another of your sessions is waiting on you, you get a message naming it (at most once every ten minutes per session).

Only the people in `CODEOID_SLACK_ALLOWED_USER_IDS` can use it, and each acts with their own key: what they can do is what that key allows.
Your sign-in ends when the key's token expires; run `/codeoid auth` again.
A key or token pasted into the DM by mistake is never sent on; revoke it, and delete the message.
2 changes: 2 additions & 0 deletions packages/protocol/src/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface PendingToolApproval {
sandboxNote?: string;
/** The sandbox lets commands reach the container runtime (Docker access). */
sandboxDocker?: boolean;
/** The tool call's message: its later state updates (answered, cancelled) reference it. */
messageId?: string;
/** A send-class fleet call's workstream, and what approving it confirms (workstreams §10; as on the card). */
targetWorkstream?: { workstream?: string; warning?: string };
}
Expand Down
21 changes: 21 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ program
.option("-p, --port <port>", "Port to listen on", "7400")
.option("--host <host>", "Host to bind to", "127.0.0.1")
.option("--no-telegram", "Disable Telegram bot")
.option("--no-slack", "Disable the Slack frontend")
.option("--no-web", "Disable Web UI")
.option(
"--local",
Expand Down Expand Up @@ -242,6 +243,26 @@ program
}
}

// Slack (enabled when CODEOID_SLACK_APP_TOKEN and CODEOID_SLACK_BOT_TOKEN are set; docs/slack.md)
if (opts.slack !== false) {
const appToken = process.env.CODEOID_SLACK_APP_TOKEN;
const botToken = process.env.CODEOID_SLACK_BOT_TOKEN;
const allowed = (process.env.CODEOID_SLACK_ALLOWED_USER_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if ((appToken || botToken) && localMode) {
// Reached through Slack's servers: a remote surface, which local mode's trust model doesn't cover.
console.warn("[codeoid] local mode: Slack frontend NOT started (it needs ZeroID identities). Run `codeoid login` and start without --local to use it.");
} else if (appToken && botToken && allowed.length > 0) {
const { SlackFrontend } = await import("./frontends/slack/index.js");
const teamId = process.env.CODEOID_SLACK_TEAM_ID?.trim();
daemon.use(new SlackFrontend(appToken, botToken, allowed, teamId ? { teamId } : {}));
} else if (appToken || botToken) {
console.log("[codeoid] Slack needs CODEOID_SLACK_APP_TOKEN, CODEOID_SLACK_BOT_TOKEN and CODEOID_SLACK_ALLOWED_USER_IDS — skipping Slack");
}
}

try {
await daemon.start();
} catch (err) {
Expand Down
9 changes: 5 additions & 4 deletions src/daemon/mcp/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const URL_CHARS = 65_536;
* and — as a backstop for what those don't know — a long run of token
* characters mixing letters and digits. Bounded.
*/
/** Well-known credential formats (codeoid's own ZeroID keys first), as a regex alternation: redacted here, refused where a key must never be sent on (the Slack DM). */
export const KNOWN_KEY_FORMATS =
"zid_sk_\\w{8,}|hf_(?:sk_)?\\w{8,}|sk-[\\w-]{8,}|sk_[a-z]+_\\w{8,}|gh[pousr]_\\w{8,}|github_pat_\\w{8,}|glpat-[\\w-]{16,}|npm_\\w{30,}|xox[abprs]-[\\w-]{8,}|xapp-[\\w-]{8,}|AKIA[0-9A-Z]{16}|AIza[\\w-]{20,}";

export function redactSecretText(text: string): string {
const cut = text.length > SCAN_CHARS;
const s = (cut ? text.slice(0, SCAN_CHARS) : text)
Expand All @@ -58,10 +62,7 @@ export function redactSecretText(text: string): string {
"$1$2$3…",
)
// well-known key formats (codeoid's own ZeroID keys first)
.replace(
/\b(?:zid_sk_\w{8,}|hf_(?:sk_)?\w{8,}|sk-[\w-]{8,}|sk_[a-z]+_\w{8,}|gh[pousr]_\w{8,}|github_pat_\w{8,}|glpat-[\w-]{16,}|npm_\w{30,}|xox[abprs]-[\w-]{8,}|AKIA[0-9A-Z]{16}|AIza[\w-]{20,})/g,
"…",
)
.replace(new RegExp(`\\b(?:${KNOWN_KEY_FORMATS})`, "g"), "…")
// long token-like path segments, then query strings and fragments
.replace(/(?<![/:])\/[A-Za-z0-9_.~+%-]{24,}(?=[/\s?#"'),;\]>:]|$)/g, "/…")
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#"']+)[?#][^\s"'),;\]>]*/gi, "$1?…")
Expand Down
29 changes: 29 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,20 @@ export class SessionManager {
* pipeline run drives phases on a live session; the phase resolves when that
* session next reaches a resting status (see #statusObserver). */
#phaseWaiters = new Map<string, (status: PhaseTurnResult["finalStatus"]) => void>();
#needsOwnerListeners = new Set<(sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void>();

/**
* Hear when a session starts waiting on its owner (an approval): its id,
* name and owner. For a frontend that tells the owner where they're needed;
* it reads the rest through the owner's own token. Returns the unsubscribe.
*/
onNeedsOwner(listener: (sessionId: string, name: string, owner: { sub: string; accountId: string; projectId: string }) => void): () => void {
this.#needsOwnerListeners.add(listener);
return () => {
this.#needsOwnerListeners.delete(listener);
};
}

/** Stable observer identity — every Session reports status transitions here. */
#statusObserver = (sessionId: string, status: SessionInfo["status"], opts?: { settled: true }): void => {
// A turn settled without a status change: only what waits on spend cares (#430).
Expand All @@ -652,6 +666,21 @@ export class SessionManager {
// owner's registered devices off-LAN. Fire-and-forget — a push hiccup must
// never touch the status path (the emit itself only ever sees an opaque
// session id, never the tool's args/description).
// Frontends that tell an owner a session waits on them (Slack's "needs you"): told the same moment, by id
// and owner only; what the session waits on is theirs to read through the owner's own token.
if (status === "waiting_approval" && this.#needsOwnerListeners.size > 0) {
const session = this.#sessions.get(sessionId);
if (session) {
const owner = { sub: session.createdBy, accountId: session.accountId, projectId: session.projectId };
for (const listener of this.#needsOwnerListeners) {
try {
listener(sessionId, session.name, owner);
} catch (err) {
console.error("[codeoid] needs-owner listener failed:", err);
}
}
}
}
if (status === "waiting_approval" && this.#pushService.enabled) {
const session = this.#sessions.get(sessionId);
if (session) {
Expand Down
2 changes: 2 additions & 0 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6530,8 +6530,10 @@ export class Session {
if (!gated) continue;
const risk = this.#shellRisk(gated.toolName, gated.input as Record<string, unknown>, gated.shellCwd);
const ws = this.#approvalWorkstreams.get(approvalId);
const messageId = this.#approvalIdToMessageId.get(approvalId);
out.push({
approvalId,
...(messageId ? { messageId } : {}),
toolName: gated.toolName,
input: gated.input,
...this.#cardRisk(risk, this.#heldWrite(gated.toolName, gated.input)),
Expand Down
13 changes: 13 additions & 0 deletions src/daemon/settings/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,19 @@ const frontends: SettingsTab = {
}),
],
},
{
id: "slack",
title: "Slack",
description: "Set the app and bot tokens and an allowlist to run the Slack frontend (docs/slack.md).",
fields: [
secret("CODEOID_SLACK_APP_TOKEN", "App token", "App-level token (xapp-…) with connections:write: the Socket Mode connection."),
secret("CODEOID_SLACK_BOT_TOKEN", "Bot token", "Bot User OAuth Token (xoxb-…) the app posts with."),
env("CODEOID_SLACK_ALLOWED_USER_IDS", "Allowed member ids", "Slack member ids permitted to use the app (required with the tokens).", {
kind: "string[]",
}),
env("CODEOID_SLACK_TEAM_ID", "Workspace id", "Only messages from this Slack workspace (T…) are taken. Unset: any workspace the app receives from.", {}),
],
},
{
id: "web",
title: "Web",
Expand Down
Loading
Loading