diff --git a/docs/superpowers/plans/2026-05-09-handoff-plugin.md b/docs/superpowers/plans/2026-05-09-handoff-plugin.md new file mode 100644 index 0000000..63e3643 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-handoff-plugin.md @@ -0,0 +1,466 @@ +# Handoff Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Claude Code plugin that relays the current session to Agentara via `/handoff`, and the Agentara-side API + session logic to receive and resume the session. + +**Architecture:** A Claude Code plugin (hook + skill) captures `/handoff` and POSTs the session_id to a new `POST /api/handoff` endpoint on Agentara. Agentara pre-creates a session record with `runner_session_id` set, sends a Feishu notification via `postMessage` (which auto-creates a thread and maps it to the session_id), and waits for the user to reply in the Feishu thread to trigger `claude --resume`. + +**Tech Stack:** TypeScript, Bun, Hono, Zod, Claude Code plugin system (hooks + skills) + +--- + +### File Map + +| File | Action | Responsibility | +|---|---|---| +| `src/shared/tasking/types/payload.ts` | Modify | Add `HandoffPayload` type | +| `src/shared/sessioning/types/session.ts` | Modify | Add `handoff` field to Session entity | +| `src/kernel/sessioning/data/schema.ts` | Modify | Add `handoff` column to sessions table | +| `src/kernel/sessioning/session-manager.ts` | Modify | Add `createHandoffSession()` method | +| `src/kernel/kernel.ts` | Modify | Expose `messageGateway` getter | +| `src/server/routes/handoff.ts` | Create | `POST /api/handoff` endpoint | +| `src/server/routes/index.ts` | Modify | Export handoffRoutes | +| `src/server/server.ts` | Modify | Mount handoff routes | +| `plugins/handoff/` | Create | Claude Code plugin (manifest, skill, hook, script) | + +### Task 1: Add HandoffPayload type + +**Files:** +- Modify: `src/shared/tasking/types/payload.ts` + +- [ ] **Step 1: Add HandoffPayload schema** + +Add this after the `ScheduledTaskPayload` definition: + +```typescript +/** + * Payload for a handoff request from a Claude Code plugin. + */ +export const HandoffPayload = z.object({ + type: z.literal("handoff"), + session_id: z.string(), + cwd: z.string().optional(), +}); +export interface HandoffPayload extends z.infer {} +``` + +- [ ] **Step 2: Export HandoffPayload from the shared barrel** + +Read `src/shared/index.ts` and verify `export * from "./tasking/types/payload"` already re-exports it. If the file re-exports from `./tasking`, the new type is automatically available. + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/tasking/types/payload.ts +git commit -m "feat: add HandoffPayload type for handoff plugin integration" +``` + +### Task 2: Add `handoff` field to Session entity and DB schema + +**Files:** +- Modify: `src/shared/sessioning/types/session.ts` +- Modify: `src/kernel/sessioning/data/schema.ts` + +- [ ] **Step 1: Add `handoff` to the shared Session zod schema** + +Read `src/shared/sessioning/types/session.ts`. Add `handoff` to the object: + +```typescript +handoff: z.boolean().default(false), +``` + +after `runner_session_id`. + +- [ ] **Step 2: Add `handoff` column to the Drizzle schema** + +Read `src/kernel/sessioning/data/schema.ts`. Add to the `sessions` table definition: + +```typescript +handoff: integer("handoff").default(0), +``` + +after `runner_session_id`. + +- [ ] **Step 3: Commit** + +```bash +git add src/shared/sessioning/types/session.ts src/kernel/sessioning/data/schema.ts +git commit -m "feat: add handoff flag to Session entity and DB schema" +``` + +### Task 3: Add `createHandoffSession()` to SessionManager + +**Files:** +- Modify: `src/kernel/sessioning/session-manager.ts` + +- [ ] **Step 1: Add `createHandoffSession` method** + +Add this method after `createSession`: + +```typescript +/** + * Creates a session for a handoff from an external Claude Code instance. + * The session already exists in Claude's native store, so it is created + * with isNewSession: false and runnerSessionId set to signal --resume. + * + * @param sessionId - The Claude Code session identifier. + * @param options - Optional agent_type, cwd, and channel_id. + * @returns A Session instance with isNewSession: false. + * @throws SessionAlreadyExistsError if the session already exists. + */ +async createHandoffSession( + sessionId: string, + options?: SessionResolveOptions, +): Promise { + if (this.existsSession(sessionId)) { + throw new SessionAlreadyExistsError(sessionId); + } + + const agentType = options?.agentType ?? config.agents.default.type; + const cwd = options?.cwd ?? config.paths.home; + const channelId = options?.channelId ?? config.messaging.default_channel_id; + const now = Date.now(); + + this._db + .insert(sessions) + .values({ + id: sessionId, + agent_type: agentType, + cwd, + channel_id: channelId, + runner_session_id: sessionId, + first_message: "Session handed off from Claude Code", + handoff: 1, + last_message_created_at: null, + created_at: now, + updated_at: now, + }) + .run(); + + this._logger.info(`Creating handoff session: ${sessionId}`); + const session = new Session(sessionId, agentType, { + isNewSession: false, + cwd, + runnerSessionId: sessionId, + }); + this._attachWriter(session, sessionId); + + return session; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/kernel/sessioning/session-manager.ts +git commit -m "feat: add createHandoffSession for external Claude Code session relay" +``` + +### Task 4: Expose messageGateway on Kernel + +**Files:** +- Modify: `src/kernel/kernel.ts` + +- [ ] **Step 1: Add messageGateway getter** + +After the `taskDispatcher` getter, add: + +```typescript +get messageGateway(): MultiChannelMessageGateway { + return this._messageGateway; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/kernel/kernel.ts +git commit -m "feat: expose messageGateway on Kernel for handoff route access" +``` + +### Task 5: Create POST /api/handoff endpoint + +**Files:** +- Create: `src/server/routes/handoff.ts` +- Modify: `src/server/routes/index.ts` +- Modify: `src/server/server.ts` + +- [ ] **Step 1: Create `src/server/routes/handoff.ts`** + +```typescript +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { kernel } from "@/kernel"; +import { HandoffPayload, createLogger } from "@/shared"; + +const logger = createLogger("handoff-routes"); + +/** + * Handoff-related route group. + * Receives session relay requests from the Claude Code handoff plugin. + */ +export const handoffRoutes = new Hono().post( + "/", + zValidator("json", HandoffPayload), + async (c) => { + const body = c.req.valid("json"); + + try { + const session = await kernel.sessionManager.createHandoffSession( + body.session_id, + { cwd: body.cwd }, + ); + + await kernel.messageGateway.postMessage({ + role: "assistant", + session_id: session.id, + content: [ + { + type: "text", + text: `Session \`${body.session_id}\` has been handed off from Claude Code. Reply to this message to continue.`, + }, + ], + }); + + return c.json({ status: "notified", session_id: session.id }); + } catch (err) { + if ( + err instanceof Error && + err.name === "SessionAlreadyExistsError" + ) { + return c.json( + { error: "Session already exists", session_id: body.session_id }, + 409, + ); + } + logger.error({ err }, "Failed to create handoff session"); + return c.json({ error: "Internal server error" }, 500); + } + }, +); +``` + +- [ ] **Step 2: Export and mount the route** + +Add to `src/server/routes/index.ts`: +```typescript +export { handoffRoutes } from "./handoff"; +``` + +Add to imports in `src/server/server.ts`: +```typescript +import { + cronjobsRoutes, + handoffRoutes, + healthRoutes, + memoryRoutes, + sessionRoutes, + skillsRoutes, + taskRoutes, + usageRoutes, +} from "./routes"; +``` + +Mount in `createApp()` after the existing routes: +```typescript +.route("/api/handoff", handoffRoutes) +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/server/routes/handoff.ts src/server/routes/index.ts src/server/server.ts +git commit -m "feat: add POST /api/handoff endpoint" +``` + +### Task 6: Create the handoff Claude Code plugin + +**Files:** +- Create: `plugins/handoff/.claude-plugin/plugin.json` +- Create: `plugins/handoff/skills/handoff/SKILL.md` +- Create: `plugins/handoff/hooks/hooks.json` +- Create: `plugins/handoff/scripts/handoff.ts` + +- [ ] **Step 1: Create plugin.json** + +```json +{ + "name": "handoff", + "version": "0.1.0", + "description": "Handoff the current Claude Code session to Agentara for continued execution", + "author": { "name": "zhangwei.justin" }, + "userConfig": { + "agentara_endpoint": { + "type": "string", + "title": "Agentara API endpoint", + "description": "Agentara server base URL, defaults to http://localhost:1984" + } + } +} +``` + +- [ ] **Step 2: Create SKILL.md** + +```markdown +--- +name: handoff +description: Relay the current Claude Code session to Agentara for continued execution +--- + +# Handoff + +When invoked via `/handoff`, acknowledge the handoff with a brief message: + +> Handing off this session to Agentara. Your local session will end now, and Agentara will continue where we left off. +``` + +- [ ] **Step 3: Create hooks.json** + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bun ${CLAUDE_PLUGIN_ROOT}/scripts/handoff.ts" + } + ] + } + ] + } +} +``` + +- [ ] **Step 4: Create handoff.ts** + +```typescript +/** + * Hook script for the handoff plugin. + * + * Reads hook JSON from stdin. If the user typed /handoff, extracts + * session_id and cwd, posts them to Agentara, and signals Claude + * Code to stop. Otherwise passes through. + */ + +interface HookInput { + session_id: string; + cwd: string; + prompt: string; +} + +async function main(): Promise { + let input: HookInput; + try { + const raw = await Bun.stdin.text(); + input = JSON.parse(raw); + } catch { + process.stdout.write(JSON.stringify({ continue: true }) + "\n"); + process.exit(0); + } + + if (!input.prompt.startsWith("/handoff")) { + process.stdout.write(JSON.stringify({ continue: true }) + "\n"); + process.exit(0); + } + + const endpoint = + Bun.env.CLAUDE_PLUGIN_OPTION_AGENTARA_ENDPOINT || "http://localhost:1984"; + + try { + const res = await fetch(`${endpoint}/api/handoff`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "handoff", + session_id: input.session_id, + cwd: input.cwd, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + process.stdout.write( + JSON.stringify({ + continue: true, + systemMessage: + "Handoff failed: " + (err.error || res.statusText), + }) + "\n", + ); + process.exit(0); + } + + process.stdout.write( + JSON.stringify({ + continue: false, + stopReason: "Session handed off to Agentara", + }) + "\n", + ); + } catch { + process.stdout.write( + JSON.stringify({ + continue: true, + systemMessage: + "Handoff failed: Agentara unreachable. Is the server running?", + }) + "\n", + ); + } + process.exit(0); +} + +main(); +``` + +- [ ] **Step 5: Commit** + +```bash +git add plugins/ +git commit -m "feat: add handoff Claude Code plugin" +``` + +### Task 7: Verify end-to-end + +- [ ] **Step 1: Run full type check** + +Run: `bun check` +Expected: Clean output, no type errors. + +- [ ] **Step 2: Start Agentara and test the handoff endpoint** + +Run: `bun run dev` (in one terminal) + +Test the endpoint directly: +```bash +curl -X POST http://localhost:1984/api/handoff \ + -H "Content-Type: application/json" \ + -d '{"type":"handoff","session_id":"test-123","cwd":"/tmp"}' +``` +Expected: `{"status":"notified","session_id":"test-123"}` + +Test idempotency: +```bash +curl -X POST http://localhost:1984/api/handoff \ + -H "Content-Type: application/json" \ + -d '{"type":"handoff","session_id":"test-123","cwd":"/tmp"}' +``` +Expected: 409 `{"error":"Session already exists","session_id":"test-123"}` + +- [ ] **Step 3: Install the plugin and test /handoff** + +Install the plugin: `/plugin install directory:plugins/handoff` + +In a Claude Code session, type `/handoff`. Verify: +- Local session ends with "Session handed off to Agentara" +- Feishu notification received (reply thread with "Reply here to continue") +- Reply to the Feishu notification +- Agentara dispatches the task and runs `claude --resume ` + +- [ ] **Step 4: Commit any remaining changes** + +```bash +git add -A +git commit -m "chore: finalize handoff integration" +``` \ No newline at end of file diff --git a/docs/superpowers/specs/2026-05-09-handoff-plugin-design.md b/docs/superpowers/specs/2026-05-09-handoff-plugin-design.md new file mode 100644 index 0000000..c737323 --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-handoff-plugin-design.md @@ -0,0 +1,114 @@ +# Handoff Plugin Design + +**Date:** 2026-05-09 +**Status:** Draft + +## Overview + +Handoff is a Claude Code plugin that relays an active Claude Code session to Agentara. The user types `/handoff` to transfer their session; the local session ends, and Agentara takes over by resuming the same Claude session. + +## Architecture + +``` +Claude Code (local) Agentara +───────────────── ───────── +/handoff invoked + → hook script extracts + session_id, cwd + → POST /api/handoff ──────────────→ sends Feishu notification + → local session ends (with confirm button) + + user confirms in Feishu + → dispatch task + → resolveSession(session_id) + → claude --resume +``` + +## Plugin Side + +### File Structure + +``` +handoff/ + .claude-plugin/ + plugin.json + skills/ + handoff/ + SKILL.md + hooks/ + hooks.json + scripts/ + handoff.ts +``` + +### plugin.json + +```json +{ + "name": "handoff", + "version": "0.1.0", + "description": "Handoff the current Claude Code session to Agentara", + "author": { "name": "zhangwei.justin" }, + "userConfig": { + "agentara_endpoint": { + "type": "string", + "title": "Agentara API endpoint", + "description": "Agentara server base URL, defaults to http://localhost:1984" + } + } +} +``` + +### SKILL.md + +Defines the `/handoff` slash command. When invoked, Claude acknowledges the handoff with a brief message and stops. + +### hooks.json + +Registers a `UserPromptSubmit` hook. On every prompt submission, `handoff.ts` is executed. + +### handoff.ts (hook script) + +1. Read hook JSON from stdin, extract `session_id`, `cwd`, `prompt` +2. If `prompt` does not start with `/handoff`, output `{"continue":true}` and exit 0 +3. If `/handoff`, POST to `${endpoint}/api/handoff` with body `{session_id, cwd}` +4. On success, output `{"continue":false, "stopReason":"Session handed off to Agentara"}` and exit 0 +5. On failure (Agentara unreachable), output `{"continue":true, "systemMessage":"Handoff failed: "}` and exit 0 (non-blocking — user can retry) + +## Agentara Side + +### API Endpoint + +`POST /api/handoff` + +``` +Request: { session_id: string, cwd?: string } +Response: { status: "notified", session_id: string } +``` + +### Flow + +1. **Receive handoff request** — endpoint validates input, sends Feishu notification with confirm/decline buttons +2. **User confirms** — Feishu callback creates an `InboundMessageTaskPayload` with `session_id` and message `"Please continue where we left off"` +3. **Task dispatched** — TaskDispatcher resolves the session and runs `claude --resume ` via ClaudeAgentRunner + +### SessionManager Changes + +`resolveSession` needs a `mode` option to distinguish handoff from normal creation. When `mode: "handoff"`, the session is created with `isNewSession: false` and `runnerSessionId` set to the session_id — signaling the runner to use `--resume` rather than `--session-id`. + +### ClaudeAgentRunner Changes + +Currently `ClaudeAgentRunner` uses `--session-id ` when `isNewSession: true` and `--resume ` when `isNewSession: false`. For handoff sessions, `isNewSession` is `false` (the session already exists in Claude's native store), and `runnerSessionId` carries the Claude session ID. The runner already handles this case — the handoff flow just needs SessionManager to pass the right options. + +### No Session Pre-creation + +The handoff endpoint does not touch the database. It only sends a notification. The session is created by SessionManager only when the user confirms and a task is dispatched — the existing `_handleInboundMessageTask` flow handles this naturally via `sessionManager.resolveSession()`. + +## Error Handling + +| Scenario | Behavior | +|---|---| +| Agentara unreachable | Hook script returns non-blocking error, local session continues, user can retry | +| Invalid session_id | Agentara returns 400, hook displays error, local session continues | +| Handoff session already exists in Agentara | resolveSession resumes it (idempotent) | +| User declines in Feishu | No task dispatched, session not created | \ No newline at end of file diff --git a/drizzle/0010_remarkable_malice.sql b/drizzle/0010_remarkable_malice.sql new file mode 100644 index 0000000..6c58a68 --- /dev/null +++ b/drizzle/0010_remarkable_malice.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `handoff` integer DEFAULT 0; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..5da7bb6 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,251 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "44d9caba-533b-42eb-9eda-12fea8b19954", + "prevId": "43a71028-b277-4802-becc-9bc794d5744d", + "tables": { + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handoff": { + "name": "handoff", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 13e191a..bc1f2c5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773500000000, "tag": "0009_blue_proteus", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1778341150681, + "tag": "0010_remarkable_malice", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plugins/handoff/.claude-plugin/plugin.json b/plugins/handoff/.claude-plugin/plugin.json new file mode 100644 index 0000000..5240949 --- /dev/null +++ b/plugins/handoff/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "handoff", + "version": "0.1.0", + "description": "Handoff the current Claude Code session to Agentara for continued execution", + "author": { "name": "zhangwei.justin" }, + "userConfig": { + "agentara_endpoint": { + "type": "string", + "title": "Agentara API endpoint", + "description": "Agentara server base URL, defaults to http://localhost:1984" + } + } +} \ No newline at end of file diff --git a/plugins/handoff/hooks/hooks.json b/plugins/handoff/hooks/hooks.json new file mode 100644 index 0000000..7915b04 --- /dev/null +++ b/plugins/handoff/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bun ${CLAUDE_PLUGIN_ROOT}/scripts/handoff.ts" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/plugins/handoff/scripts/handoff.ts b/plugins/handoff/scripts/handoff.ts new file mode 100644 index 0000000..770b3d2 --- /dev/null +++ b/plugins/handoff/scripts/handoff.ts @@ -0,0 +1,77 @@ +/** + * Hook script for the handoff plugin. + * + * Reads hook JSON from stdin. If the user typed /handoff, extracts + * session_id and cwd, posts them to Agentara, and signals Claude + * Code to stop. Otherwise passes through. + */ + +interface HookInput { + session_id: string; + cwd: string; + prompt: string; +} + +async function main(): Promise { + let input: HookInput; + try { + const raw = await Bun.stdin.text(); + input = JSON.parse(raw); + } catch { + process.stdout.write(JSON.stringify({ continue: true }) + "\n"); + process.exit(0); + } + + if (!input.prompt.startsWith("/handoff")) { + process.stdout.write(JSON.stringify({ continue: true }) + "\n"); + process.exit(0); + } + + const endpoint = + Bun.env.CLAUDE_PLUGIN_OPTION_AGENTARA_ENDPOINT || "http://localhost:1984"; + + const base = endpoint.replace(/\/+$/, ""); + + try { + const res = await fetch(`${base}/api/handoff`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "handoff", + session_id: input.session_id, + cwd: input.cwd, + }), + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { error?: string }; + process.stdout.write( + JSON.stringify({ + continue: true, + systemMessage: + "Handoff failed: " + (err.error || res.statusText), + }) + "\n", + ); + process.exit(0); + } + + process.stdout.write( + JSON.stringify({ + continue: false, + stopReason: "Session handed off to Agentara", + }) + "\n", + ); + } catch { + process.stdout.write( + JSON.stringify({ + continue: true, + systemMessage: + "Handoff failed: Agentara unreachable. Is the server running?", + }) + "\n", + ); + } + process.exit(0); +} + +void main(); \ No newline at end of file diff --git a/plugins/handoff/skills/handoff/SKILL.md b/plugins/handoff/skills/handoff/SKILL.md new file mode 100644 index 0000000..3c115c0 --- /dev/null +++ b/plugins/handoff/skills/handoff/SKILL.md @@ -0,0 +1,10 @@ +--- +name: handoff +description: Relay the current Claude Code session to Agentara for continued execution +--- + +# Handoff + +When invoked via `/handoff`, acknowledge the handoff with a brief message: + +> Handing off this session to Agentara. Your local session will end now, and Agentara will continue where we left off. \ No newline at end of file diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 15b5a80..fca8da1 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -51,6 +51,10 @@ class Kernel { return this._taskDispatcher; } + get messageGateway(): MultiChannelMessageGateway { + return this._messageGateway; + } + get honoServer(): HonoServer { return this._honoServer; } diff --git a/src/kernel/sessioning/data/schema.ts b/src/kernel/sessioning/data/schema.ts index 00cf487..9010435 100644 --- a/src/kernel/sessioning/data/schema.ts +++ b/src/kernel/sessioning/data/schema.ts @@ -19,6 +19,8 @@ export const sessions = sqliteTable("sessions", { first_message: text("first_message").notNull().default(""), /** Runner-specific session/thread id (e.g. Codex thread id) for resume. */ runner_session_id: text("runner_session_id"), + /** Whether this session was created via handoff from an external Claude Code instance. */ + handoff: integer("handoff").default(0), /** Epoch milliseconds of the most recent message, or null if no messages yet. */ last_message_created_at: integer("last_message_created_at"), /** Epoch milliseconds when the session was created. */ diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 174440a..77fdcd4 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -144,6 +144,56 @@ export class SessionManager { return session; } + /** + * Creates a session for a handoff from an external Claude Code instance. + * The session already exists in Claude's native store, so it is created + * with isNewSession: false and runnerSessionId set to signal --resume. + * + * @param sessionId - The Claude Code session identifier. + * @param options - Optional agent_type, cwd, and channel_id. + * @returns A Session instance with isNewSession: false. + * @throws SessionAlreadyExistsError if the session already exists. + */ + async createHandoffSession( + sessionId: string, + options?: SessionResolveOptions, + ): Promise { + if (this.existsSession(sessionId)) { + throw new SessionAlreadyExistsError(sessionId); + } + + const agentType = options?.agentType ?? config.agents.default.type; + const cwd = options?.cwd ?? config.paths.home; + const channelId = options?.channelId ?? config.messaging.default_channel_id; + const now = Date.now(); + + this._db + .insert(sessions) + .values({ + id: sessionId, + agent_type: agentType, + cwd, + channel_id: channelId, + runner_session_id: sessionId, + first_message: "Session handed off from Claude Code", + handoff: 1, + last_message_created_at: null, + created_at: now, + updated_at: now, + }) + .run(); + + this._logger.info(`Creating handoff session: ${sessionId}`); + const session = new Session(sessionId, agentType, { + isNewSession: false, + cwd, + runnerSessionId: sessionId, + }); + this._attachWriter(session, sessionId); + + return session; + } + /** * Resumes an existing session by reading its metadata from the database. * @param sessionId - The session identifier. @@ -190,7 +240,8 @@ export class SessionManager { .from(sessions) .orderBy(desc(sessions.updated_at)) .limit(limit) - .all(); + .all() + .map((row) => this._toSessionEntity(row)); } /** @@ -256,6 +307,13 @@ export class SessionManager { .run(); } + private _toSessionEntity(row: typeof sessions.$inferSelect): SessionEntity { + return { + ...row, + handoff: row.handoff === 1, + }; + } + private _attachWriter(session: Session, sessionId: string): void { const fileWriter = new SessionJSONLWriter(sessionId); const logWriter = new SessionLogWriter(sessionId); diff --git a/src/server/routes/handoff.ts b/src/server/routes/handoff.ts new file mode 100644 index 0000000..1e2b480 --- /dev/null +++ b/src/server/routes/handoff.ts @@ -0,0 +1,57 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { kernel } from "@/kernel"; +import { HandoffPayload, createLogger } from "@/shared"; + +const logger = createLogger("handoff-routes"); + +/** + * Handoff-related route group. + * Receives session relay requests from the Claude Code handoff plugin. + */ +export const handoffRoutes = new Hono().post( + "/", + zValidator("json", HandoffPayload), + async (c) => { + const body = c.req.valid("json"); + + try { + const session = await kernel.sessionManager.createHandoffSession( + body.session_id, + { cwd: body.cwd }, + ); + + try { + await kernel.messageGateway.postMessage({ + role: "assistant", + session_id: session.id, + content: [ + { + type: "text", + text: `Session \`${body.session_id}\` has been handed off from Claude Code. Reply to this message to continue.`, + }, + ], + }); + } catch (notifyErr) { + logger.error({ err: notifyErr }, "Failed to send handoff notification"); + kernel.sessionManager.removeSession(session.id); + return c.json({ error: "Failed to send notification" }, 500); + } + + return c.json({ status: "notified", session_id: session.id }); + } catch (err) { + if ( + err instanceof Error && + err.name === "SessionAlreadyExistsError" + ) { + return c.json( + { error: "Session already exists", session_id: body.session_id }, + 409, + ); + } + logger.error({ err }, "Failed to create handoff session"); + return c.json({ error: "Internal server error" }, 500); + } + }, +); \ No newline at end of file diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index d38c893..996a602 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,4 +1,5 @@ export { cronjobsRoutes } from "./cronjobs"; +export { handoffRoutes } from "./handoff"; export { healthRoutes } from "./health"; export { memoryRoutes } from "./memory"; export { sessionRoutes } from "./sessions"; diff --git a/src/server/server.ts b/src/server/server.ts index 90ed345..ba6cbf9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -11,6 +11,7 @@ import { createLogger } from "@/shared"; import { cronjobsRoutes, + handoffRoutes, healthRoutes, memoryRoutes, sessionRoutes, @@ -34,6 +35,7 @@ function createApp() { // Routes .route("/api", healthRoutes) .route("/api/cronjobs", cronjobsRoutes) + .route("/api/handoff", handoffRoutes) .route("/api/memory", memoryRoutes) .route("/api/sessions", sessionRoutes) .route("/api/skills", skillsRoutes) diff --git a/src/shared/sessioning/types/session.ts b/src/shared/sessioning/types/session.ts index bceefb4..30ed732 100644 --- a/src/shared/sessioning/types/session.ts +++ b/src/shared/sessioning/types/session.ts @@ -19,6 +19,8 @@ export const Session = z.object({ first_message: z.string(), /** Runner-specific session/thread id (e.g. Codex thread id), if available. */ runner_session_id: z.string().optional().nullable(), + /** Whether this session was created via handoff from an external Claude Code instance. */ + handoff: z.boolean().default(false), /** Epoch milliseconds of the most recent message, or null if no messages yet. */ last_message_created_at: z.number().nullable(), /** Epoch milliseconds when the session was created. */ diff --git a/src/shared/tasking/types/payload.ts b/src/shared/tasking/types/payload.ts index 1734cf8..5e7811c 100644 --- a/src/shared/tasking/types/payload.ts +++ b/src/shared/tasking/types/payload.ts @@ -26,6 +26,16 @@ export interface ScheduledTaskPayload extends z.infer< typeof ScheduledTaskPayload > {} +/** + * Payload for a handoff request from a Claude Code plugin. + */ +export const HandoffPayload = z.object({ + type: z.literal("handoff"), + session_id: z.string(), + cwd: z.string().optional(), +}); +export interface HandoffPayload extends z.infer {} + /** * Describes "when" a scheduled task should run. * Either `at`/`delay` (one-shot) or `pattern`/`every` (recurring) must be provided. @@ -71,5 +81,9 @@ export interface TaskSchedule extends z.infer {} export const TaskPayload = z.discriminatedUnion("type", [ InboundMessageTaskPayload, ScheduledTaskPayload, + HandoffPayload, ]); -export type TaskPayload = InboundMessageTaskPayload | ScheduledTaskPayload; +export type TaskPayload = + | InboundMessageTaskPayload + | ScheduledTaskPayload + | HandoffPayload;