Skip to content
Merged
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ new version heading in the same commit.

## [Unreleased]

## [0.191.0] — 2026-07-14
### Added
- **Chat — a plain-language window onto a claude-code run for non-technical teammates.** A new **Chat**
page (nav next to Agents) renders a session as a messaging app instead of a terminal: message bubbles,
friendly activity cards ("Sent a Slack message" ✓ / "Read a file"), and **inline approvals/questions**
as Approve/Decline buttons — the governance surface in language support/sales/marketing can act on. It
reads the session's claude transcript (`GET /api/sessions/:id/conversation`, parsed by
`src/edge/conversation.ts`) and drives the human's turns via `POST /api/sessions/:id/reply` and
`POST /api/chat/start` — reusing the **exact** resident-deliver / transcript-resume path Slack
thread-continuity already uses (`deliverToResident`/`reviveResident`), so no governance, gate, or DB
changes: the gate hook + approvals still mediate every effect. Message-level polling (2s), no terminal.
(`src/edge/conversation.ts`, `src/server.ts`, `web/src/App.tsx`, `web/src/lib/api.ts`.)

## [0.190.0] — 2026-07-14
### Added
- **Callee agents can poke the caller back when they're really done — async delegation, no polling.** A
Expand Down Expand Up @@ -38,7 +51,6 @@ new version heading in the same commit.
*webhook* url, not our (present) top-level one. Fix: **omit `hook_attributes` entirely** — that's an App
with no webhook, exactly what we want, and no required url. (`src/server.ts` `githubAppManifest`;
`scripts/github-per-member-test.cjs` now 79/79.)

## [0.188.0] — 2026-07-14
### Added
- **Set the GitHub App slug by hand when it can't be auto-detected.** The "Install the App" button needs
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.190.0",
"version": "0.191.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
180 changes: 180 additions & 0 deletions src/edge/conversation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// A NON-TECHNICAL view of a claude-code session.
//
// Claude Code writes a structured JSONL transcript per session (one file, named by the pinned
// `--session-id`, under `$CLAUDE_CONFIG_DIR/projects/<escaped-cwd>/<id>.jsonl`). agent-os already
// pins that id (`term_sessions.claude_session_id`), so we can locate the file by NAME alone —
// regardless of which agent workspace (cwd) the run used.
//
// This module turns that raw transcript (thinking blocks, tool_use JSON, tool_result payloads) into
// a clean chat timeline a support/sales/marketing user can read: plain message bubbles + friendly
// "activity" cards ("Sent a Slack message", "Read a file") instead of tool JSON. It is READ-ONLY and
// has no dependency on how the session is driven — the native chat UI and the ttyd terminal are two
// windows onto the same underlying run.

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

/** One entry in the human-readable timeline. */
export type ChatTurn =
| { kind: 'user'; text: string; ts: number }
| { kind: 'assistant'; text: string; ts: number }
| {
kind: 'activity';
tool: string;
/** friendly one-liner, e.g. "Sent a Slack message" */
label: string;
/** short human hint (a filename, a URL host, a query) — the UI may hide this behind a toggle */
detail?: string;
status: 'running' | 'ok' | 'error';
ts: number;
};

export interface Conversation {
turns: ChatTurn[];
/** true once we found and parsed a transcript file (false = the run hasn't written one yet) */
found: boolean;
}

const claudeConfigDir = (): string => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');

/** Locate `<claudeSessionId>.jsonl` under any project dir. Filename is unique, so cwd escaping is irrelevant. */
function findTranscript(claudeSessionId: string): string | undefined {
const projects = path.join(claudeConfigDir(), 'projects');
let dirs: string[];
try {
dirs = fs.readdirSync(projects);
} catch {
return undefined;
}
const wanted = `${claudeSessionId}.jsonl`;
for (const d of dirs) {
const candidate = path.join(projects, d, wanted);
if (fs.existsSync(candidate)) return candidate;
}
return undefined;
}

/** "slack_send" / "getFileContent" → "Slack send" / "Get file content" — last-resort humanizer. */
function humanize(raw: string): string {
const words = raw
.replace(/[_-]+/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.trim()
.toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
}

const basename = (p: unknown): string | undefined =>
typeof p === 'string' && p ? p.split('/').pop() || p : undefined;

const host = (u: unknown): string | undefined => {
if (typeof u !== 'string') return undefined;
try {
return new URL(u).host;
} catch {
return u.slice(0, 60);
}
};

const clip = (s: unknown, n = 80): string | undefined =>
typeof s === 'string' && s ? (s.length > n ? s.slice(0, n) + '…' : s) : undefined;

/** Map a tool_use block to a friendly label + short detail, tuned for a non-technical reader. */
function friendlyTool(name: string, input: Record<string, unknown>): { label: string; detail?: string } {
switch (name) {
case 'Bash':
return { label: 'Ran a command', detail: clip(input.command) };
case 'Read':
return { label: 'Read a file', detail: basename(input.file_path) };
case 'Edit':
case 'Write':
case 'NotebookEdit':
return { label: 'Updated a file', detail: basename(input.file_path) };
case 'Grep':
case 'Glob':
return { label: 'Searched the files', detail: clip(input.pattern) };
case 'WebFetch':
return { label: 'Read a web page', detail: host(input.url) };
case 'WebSearch':
return { label: 'Searched the web', detail: clip(input.query) };
case 'Task':
case 'Agent':
return { label: 'Asked a helper to dig in', detail: clip(input.description) };
case 'ToolSearch':
return { label: 'Looked up the tools it needs' };
case 'TodoWrite':
return { label: 'Updated its plan' };
}
// agent-os MCP tools arrive as mcp__<server>__<tool>.
const mcp = name.match(/^mcp__[^_]*(?:_[^_]+)*__(.+)$/) || name.match(/^mcp__.+?__(.+)$/);
const bare = mcp ? mcp[1] : name;
if (/^slack_/.test(bare)) return { label: 'Sent a Slack message', detail: clip(input.text ?? input.message ?? input.channel) };
if (/^discord_/.test(bare)) return { label: 'Sent a Discord message', detail: clip(input.content ?? input.message) };
if (/^(remember|recall|revise|forget)$/.test(bare)) return { label: 'Used its memory' };
if (/^kb_/.test(bare)) return { label: 'Used the knowledge base', detail: clip(input.query ?? input.slug ?? input.title) };
if (/^task_/.test(bare)) return { label: 'Updated the task board', detail: clip(input.title) };
if (/^(report|update|notify|publish)$/.test(bare)) return { label: 'Posted an update' };
if (bare === 'ask') return { label: 'Asked a question', detail: clip(input.question) };
if (/^secret_/.test(bare)) return { label: 'Used a stored credential', detail: clip(input.key) };
if (bare === 'directory_lookup') return { label: 'Looked someone up' };
return { label: humanize(bare), detail: clip((input as any).query ?? (input as any).title) };
}

/** Parse a claude transcript line's `message.content` into ordered turns; merge tool results by id. */
export function readConversation(claudeSessionId: string): Conversation {
const file = findTranscript(claudeSessionId);
if (!file) return { turns: [], found: false };

let raw: string;
try {
raw = fs.readFileSync(file, 'utf8');
} catch {
return { turns: [], found: false };
}

const turns: ChatTurn[] = [];
// tool_use_id → index in `turns`, so the tool_result on the NEXT user message resolves the card.
const activityById = new Map<string, number>();

for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let o: any;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (o.type !== 'assistant' && o.type !== 'user') continue;
const ts = Date.parse(o.timestamp) || 0;
const content = o.message?.content;

if (typeof content === 'string') {
const text = content.trim();
if (text) turns.push({ kind: o.type === 'user' ? 'user' : 'assistant', text, ts });
continue;
}
if (!Array.isArray(content)) continue;

for (const b of content) {
if (!b || typeof b !== 'object') continue;
if (b.type === 'text') {
const text = String(b.text || '').trim();
if (text) turns.push({ kind: o.type === 'user' ? 'user' : 'assistant', text, ts });
} else if (b.type === 'tool_use') {
const { label, detail } = friendlyTool(String(b.name || ''), (b.input || {}) as Record<string, unknown>);
activityById.set(String(b.id), turns.length);
turns.push({ kind: 'activity', tool: String(b.name || ''), label, detail, status: 'running', ts });
} else if (b.type === 'tool_result') {
const idx = activityById.get(String(b.tool_use_id));
if (idx != null) {
const card = turns[idx];
if (card && card.kind === 'activity') card.status = b.is_error ? 'error' : 'ok';
}
}
// thinking blocks are deliberately dropped — noise for a non-technical reader.
}
}

return { turns, found: true };
}
45 changes: 44 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { exampleCapabilities } from './capabilities/examples';
import { evaluate } from './observability/evaluation';
import { TerminalManager, AGENT_OS_OPERATING_NOTES } from './terminal';
import { classifyActivity, clipText, ActivityCategory, ActivityEffect } from './state/session-activity';
import { Automation, Automations, nextCronRun, derivedConcurrencyCap } from './edge/automations';
import { readConversation } from './edge/conversation';
import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle } from './edge/automations';
import { SlackSocket } from './edge/slack-socket';
import { DiscordSocket } from './edge/discord-socket';
import { DreamingEngine, recommendationResolved } from './edge/dreaming';
Expand Down Expand Up @@ -1893,6 +1894,48 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
const s = tm.createSession(agent, String(b.title || task), task, me.id);
return sendJson(res, 200, { id: s.id, tmux: s.tmux });
}
// ─── Native chat surface (non-technical) — a plain-language window onto a claude-code run, an
// alternative to the ttyd terminal. Governance is UNCHANGED: the gate hook + approvals still
// mediate every effect; this only reads the transcript and drives replies via the same
// resident deliver/revive path Slack thread-continuity uses.
//
// Start a chat: spawn a RESIDENT interactive session (kept warm for fast follow-ups, like Slack chat).
if (method === 'POST' && p === '/api/chat/start') {
const b = await readBody(req);
const agent = String(b.agent || '').trim();
const message = String(b.message || '').trim();
if (!agent || !message) return sendJson(res, 400, { error: 'agent and message are required' });
if (!os.team.canRun(me, agent)) return sendJson(res, 403, { error: `you are not assigned to run "${agent}"` });
// spawnedBy=chat provenance, runAs=me (accountable human for the turn), resident=true.
const s = tm.createSession(agent, chatTitle(message, agent), message, `chat:${me.id}`, false, undefined, undefined, me.id, undefined, true);
return sendJson(res, 200, { id: s.id, tmux: s.tmux });
}
// Read the friendly conversation timeline for a session (poll this like the rest of the console).
const convoMatch = p.match(/^\/api\/sessions\/([\w-]+)\/conversation$/);
if (method === 'GET' && convoMatch) {
const id = convoMatch[1];
const agent = tm.sessionAgent(id);
if (!agent) return sendJson(res, 404, { error: 'unknown session' });
if (!tm.canViewSession(id, me)) return sendJson(res, 403, { error: 'not allowed to view this session' });
const claudeId = tm.sessionClaudeId(id);
const convo = claudeId ? readConversation(claudeId) : { turns: [], found: false };
return sendJson(res, 200, { agent, ...convo });
}
// Reply into a session (the human's next turn). Warm path types into the live resident pane; cold
// path (a reaped session) resumes the SAME transcript seeded with the message — identical to a Slack
// thread follow-up. The replier becomes the accountable run-as for this turn.
const chatReplyMatch = p.match(/^\/api\/sessions\/([\w-]+)\/reply$/);
if (method === 'POST' && chatReplyMatch) {
const id = chatReplyMatch[1];
if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' });
if (!tm.canViewSession(id, me)) return sendJson(res, 403, { error: 'not allowed to reply to this session' });
const b = await readBody(req);
const message = String(b.message || '').trim();
if (!message) return sendJson(res, 400, { error: 'message is required' });
if (tm.deliverToResident(id, message)) return sendJson(res, 200, { status: 'delivered' });
if (tm.reviveResident(id, message, me.id)) return sendJson(res, 200, { status: 'revived' });
return sendJson(res, 409, { error: 'this session could not accept the message' });
}
// Session activity: "which agent-os primitives did this run use?" — the session's audit stream,
// classified into a chronological timeline + a grouped count summary. Same visibility as the terminal
// (canViewSession), so a member sees the activity of the runs they can attach to, not just admins.
Expand Down
Loading
Loading