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
466 changes: 466 additions & 0 deletions docs/superpowers/plans/2026-05-09-handoff-plugin.md

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions docs/superpowers/specs/2026-05-09-handoff-plugin-design.md
Original file line number Diff line number Diff line change
@@ -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 <session_id>
```

## 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: <error>"}` 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 <session_id>` 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 <id>` when `isNewSession: true` and `--resume <id>` 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 |
1 change: 1 addition & 0 deletions drizzle/0010_remarkable_malice.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `sessions` ADD `handoff` integer DEFAULT 0;
251 changes: 251 additions & 0 deletions drizzle/meta/0010_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
9 changes: 8 additions & 1 deletion drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
"when": 1773500000000,
"tag": "0009_blue_proteus",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1778341150681,
"tag": "0010_remarkable_malice",
"breakpoints": true
}
]
}
}
Loading
Loading