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: 5 additions & 9 deletions auto-update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { spawn } from "child_process";
import type { PluginInput } from "@opencode-ai/plugin";

const PACKAGE_NAME = "opencode-session-handoff";
Expand Down Expand Up @@ -205,19 +204,16 @@ function invalidatePackage(): boolean {

async function runBunInstall(): Promise<boolean> {
try {
const proc = spawn("bun", ["install"], {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdio: "pipe",
});

const exitPromise = new Promise<number | null>((resolve) => {
proc.on("close", (code) => resolve(code));
proc.on("error", () => resolve(null));
stdout: "pipe",
stderr: "pipe",
});

const timeoutPromise = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS),
);
const exitPromise = proc.exited.then(() => "completed" as const);

const result = await Promise.race([exitPromise, timeoutPromise]);

Expand All @@ -230,7 +226,7 @@ async function runBunInstall(): Promise<boolean> {
return false;
}

return result === 0;
return proc.exitCode === 0;
} catch {
return false;
}
Expand Down
1 change: 0 additions & 1 deletion bun.lock

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

66 changes: 0 additions & 66 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildHandoffPrompt, type HandoffArgs } from "./prompt.ts";
import { isHandoffTrigger, extractGoalFromHandoff } from "./index.ts";

const baseArgs: HandoffArgs = {
previousSessionId: "ses_123",
Expand Down Expand Up @@ -96,68 +95,3 @@ describe("buildHandoffPrompt - next steps", () => {
expect(build({ next_steps: [] })).not.toContain("**Next:**");
});
});

describe("isHandoffTrigger", () => {
it("returns true for 'handoff'", () => {
expect(isHandoffTrigger("handoff")).toBe(true);
expect(isHandoffTrigger("Handoff")).toBe(true);
expect(isHandoffTrigger("HANDOFF")).toBe(true);
expect(isHandoffTrigger(" handoff ")).toBe(true);
});

it("returns true for '/handoff'", () => {
expect(isHandoffTrigger("/handoff")).toBe(true);
expect(isHandoffTrigger("/Handoff")).toBe(true);
expect(isHandoffTrigger(" /handoff ")).toBe(true);
});

it("returns true for 'session handoff'", () => {
expect(isHandoffTrigger("session handoff")).toBe(true);
expect(isHandoffTrigger("Session Handoff")).toBe(true);
});

it("returns true for 'handoff <goal>'", () => {
expect(isHandoffTrigger("handoff implement login")).toBe(true);
expect(isHandoffTrigger("handoff fix the bug")).toBe(true);
});

it("returns true for '/handoff <goal>'", () => {
expect(isHandoffTrigger("/handoff implement login")).toBe(true);
expect(isHandoffTrigger("/handoff fix tests")).toBe(true);
});

it("returns false for non-handoff messages", () => {
expect(isHandoffTrigger("implement handoff feature")).toBe(false);
expect(isHandoffTrigger("hello world")).toBe(false);
expect(isHandoffTrigger("hand off the work")).toBe(false);
});
});

describe("extractGoalFromHandoff", () => {
it("extracts goal from 'handoff <goal>'", () => {
expect(extractGoalFromHandoff("handoff implement login")).toBe("implement login");
expect(extractGoalFromHandoff("handoff fix the failing tests")).toBe("fix the failing tests");
expect(extractGoalFromHandoff("Handoff Create PR")).toBe("Create PR");
});

it("extracts goal from '/handoff <goal>'", () => {
expect(extractGoalFromHandoff("/handoff implement login")).toBe("implement login");
expect(extractGoalFromHandoff("/handoff fix tests")).toBe("fix tests");
});

it("returns null for standalone handoff", () => {
expect(extractGoalFromHandoff("handoff")).toBe(null);
expect(extractGoalFromHandoff("/handoff")).toBe(null);
expect(extractGoalFromHandoff(" handoff ")).toBe(null);
});

it("returns null for 'handoff ' with only whitespace after", () => {
expect(extractGoalFromHandoff("handoff ")).toBe(null);
expect(extractGoalFromHandoff("/handoff ")).toBe(null);
});

it("returns null for non-handoff messages", () => {
expect(extractGoalFromHandoff("hello world")).toBe(null);
expect(extractGoalFromHandoff("session handoff")).toBe(null);
});
});
92 changes: 9 additions & 83 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
import type { Message, Part } from "@opencode-ai/sdk";
import { z } from "zod";
import { buildHandoffPrompt } from "./prompt.ts";
import { createAutoUpdateHook } from "./auto-update.ts";

Expand Down Expand Up @@ -211,27 +210,10 @@ async function executeHandoff(
return `✓ Session "${newTitle}" created (${context.agent || "default"} · ${modelDisplay}). Select it from the picker.`;
}

const handoffArgsSchema = {
summary: z.string().describe("1-3 sentence summary of current state (required)"),
goal: z.string().optional().describe("Goal for the next session if user specified one"),
next_steps: z.array(z.string()).optional().describe("Array of remaining tasks"),
blocked: z.string().optional().describe("Current blocker if any"),
key_decisions: z.array(z.string()).optional().describe("Important decisions made"),
files_modified: z.array(z.string()).optional().describe("Key files that were changed"),
};

function createHandoffTool(pluginCtx: PluginContext) {
return {
description: `Generate a compact continuation prompt and start a new session with it.

**TRIGGER: When the user's message starts with "handoff" (case-insensitive), ALWAYS invoke this tool immediately.** Any text after "handoff" should be treated as the goal parameter.

Examples that MUST trigger this tool:
- "handoff" → invoke with summary only
- "handoff can you check the lint warnings" → invoke with goal="can you check the lint warnings"
- "Handoff fix the failing tests" → invoke with goal="fix the failing tests"
- "HANDOFF implement login feature" → invoke with goal="implement login feature"

When called, this tool:
1. Uses YOUR summary of what was accomplished (required)
2. Auto-fetches todo state from current session
Expand All @@ -240,8 +222,16 @@ When called, this tool:

IMPORTANT: You MUST provide a concise summary. Do not dump the entire conversation - distill it to essential context only.

Arguments (pass as JSON object):
- summary (required): 1-3 sentence summary of current state
- goal (optional): If the user said "handoff <something>" or "session_handoff <something>", extract what comes after as the goal for the next session
- next_steps (optional): Array of remaining tasks
- blocked (optional): Current blocker if any
- key_decisions (optional): Array of important decisions made
- files_modified (optional): Array of key files changed

The new session will have access to \`read_session\` tool if more context is needed later.`,
args: handoffArgsSchema,
args: {},
async execute(args: Record<string, unknown>, ctx: { sessionID: string }) {
return executeHandoff(pluginCtx, args as unknown as HandoffToolArgs, ctx.sessionID);
},
Expand Down Expand Up @@ -300,30 +290,6 @@ This tool fetches the last 20 messages which uses significant tokens. The handof
};
}

export function isHandoffTrigger(text: string): boolean {
const trimmed = text.trim().toLowerCase();
return (
trimmed === "handoff" ||
trimmed === "/handoff" ||
trimmed === "session handoff" ||
trimmed.startsWith("handoff ") ||
trimmed.startsWith("/handoff ")
);
}

export function extractGoalFromHandoff(text: string): string | null {
const trimmed = text.trim();
const lower = trimmed.toLowerCase();

if (lower.startsWith("/handoff ")) {
return trimmed.slice(9).trim() || null;
}
if (lower.startsWith("handoff ")) {
return trimmed.slice(8).trim() || null;
}
return null;
}

const HandoffPlugin: Plugin = async (ctx) => {
const autoUpdateHook = createAutoUpdateHook({
directory: ctx.directory,
Expand All @@ -343,46 +309,6 @@ const HandoffPlugin: Plugin = async (ctx) => {
client: ctx.client,
}),
},
"chat.message": async (
_input: {
sessionID: string;
agent?: string;
model?: { providerID: string; modelID: string };
messageID?: string;
variant?: string;
},
output: { message: unknown; parts: Part[] },
) => {
const textParts = output.parts.filter(
(p): p is Part & { type: "text"; text: string } =>
p.type === "text" && typeof (p as { text?: string }).text === "string",
);

for (const part of textParts) {
if (isHandoffTrigger(part.text)) {
const goal = extractGoalFromHandoff(part.text);

part.text = [
'<system-instruction priority="critical">',
"The user has triggered a session handoff. You MUST invoke the `session_handoff` tool immediately.",
"",
"DO NOT interpret this as a regular task request.",
"DO NOT continue working on any previous tasks.",
"DO NOT ask clarifying questions.",
"",
"IMMEDIATELY call the session_handoff tool with:",
"- summary: A brief summary of what was accomplished in this session",
goal ? `- goal: "${goal}"` : "- goal: (not specified)",
"- next_steps: Any remaining tasks from the todo list",
"- key_decisions: Important decisions made during the session",
"- files_modified: Key files that were changed",
"</system-instruction>",
].join("\n");

break;
}
}
},
};
};

Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-session-handoff",
"version": "1.1.5",
"version": "1.1.6",
"description": "OpenCode plugin for seamless session continuation when context windows fill up",
"keywords": [
"context-window",
Expand Down Expand Up @@ -49,8 +49,7 @@
"prepare": "husky"
},
"dependencies": {
"@opencode-ai/plugin": "1.1.12",
"zod": "4.1.8"
"@opencode-ai/plugin": "1.1.12"
},
"devDependencies": {
"@commitlint/cli": "^20.3.1",
Expand Down
4 changes: 1 addition & 3 deletions tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,5 @@ export default defineConfig({
clean: true,
outDir: "dist",
sourcemap: true,
external: ["@opencode-ai/plugin"],
noExternal: ["zod"],
inlineOnly: false,
external: ["zod", "@opencode-ai/plugin"],
});