diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 190550b..dabbb96 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1,6 +1,6 @@ { - "name": "opencode-warp", - "version": "0.1.0", + "name": "@warp-dot-dev/opencode-warp", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { @@ -50,14 +50,12 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package-lock.json b/package-lock.json index fe5b916..66644d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { - "name": "opencode-warp", - "version": "0.1.4", + "name": "@warp-dot-dev/opencode-warp", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "opencode-warp", - "version": "0.1.4", + "name": "@warp-dot-dev/opencode-warp", + "version": "0.1.5", "license": "MIT", + "dependencies": { + "undici-types": "^7.18.2", + "zod": "^4.1.8" + }, "devDependencies": { "@opencode-ai/plugin": "^1.0.0", "@types/node": "^25.5.0", @@ -63,14 +67,12 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 1640cca..776c9a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@warp-dot-dev/opencode-warp", - "version": "0.1.4", + "version": "0.1.5", "description": "Warp terminal integration for OpenCode — native notifications and more", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 38c9a56..6756093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,201 +1,198 @@ import type { Plugin } from "@opencode-ai/plugin" -import type { Event, Part, Permission } from "@opencode-ai/sdk" +import type { Event, Permission } from "@opencode-ai/sdk" import { buildPayload } from "./payload" import { warpNotify } from "./notify" +import { truncate, extractTextFromParts } from "./utils" // Must be kept in sync with the "version" field in package.json. // NOTE: do not `export` this constant — opencode's legacy plugin loader // treats every named export as a plugin function and throws if any export // is not a function ("Plugin export is not a function"). -const PLUGIN_VERSION = "0.1.4" +const PLUGIN_VERSION = "0.1.5" const NOTIFICATION_TITLE = "warp://cli-agent" -export function truncate(str: string, maxLen: number): string { - if (str.length <= maxLen) return str - return str.slice(0, maxLen - 3) + "..." -} - -export function extractTextFromParts(parts: Part[]): string { - return parts - .filter((p): p is Part & { type: "text"; text: string } => - p.type === "text" && "text" in p && Boolean(p.text), - ) - .map((p) => p.text) - .join(" ") -} - function sendPermissionNotification(perm: Permission, cwd: string): void { - const sessionId = perm.sessionID - const toolName = perm.type || "unknown" - const metadata = perm.metadata || {} - - let toolPreview = "" - if (typeof metadata.command === "string") { - toolPreview = metadata.command - } else if (typeof metadata.file_path === "string") { - toolPreview = metadata.file_path as string - } else if (typeof metadata.filePath === "string") { - toolPreview = metadata.filePath as string - } else { - const raw = JSON.stringify(metadata) - toolPreview = raw.slice(0, 80) - } - - let summary = `Wants to run ${toolName}` - if (toolPreview) { - summary += `: ${truncate(toolPreview, 120)}` - } - - const body = buildPayload("permission_request", sessionId, cwd, { - summary, - tool_name: toolName, - tool_input: metadata, - }) - warpNotify(NOTIFICATION_TITLE, body) + const sessionId = perm.sessionID + const toolName = perm.type || "unknown" + const metadata = perm.metadata || {} + + let toolPreview = "" + if (typeof metadata.command === "string") { + toolPreview = metadata.command + } else if (typeof metadata.file_path === "string") { + toolPreview = metadata.file_path as string + } else if (typeof metadata.filePath === "string") { + toolPreview = metadata.filePath as string + } else { + const raw = JSON.stringify(metadata) + toolPreview = raw.slice(0, 80) + } + + let summary = `Wants to run ${toolName}` + if (toolPreview) { + summary += `: ${truncate(toolPreview, 120)}` + } + + const body = buildPayload("permission_request", sessionId, cwd, { + summary, + tool_name: toolName, + tool_input: metadata, + }) + warpNotify(NOTIFICATION_TITLE, body) } export const WarpPlugin: Plugin = async ({ client, directory }) => { - if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) { - await client.app.log({ - body: { - service: "opencode-warp", - level: "warn", - message: - "⚠️ Detected unsupported Warp version. Please update Warp to use this plugin.", - }, - }) - return {} - } - - await client.app.log({ - body: { - service: "opencode-warp", - level: "info", - message: "Warp plugin initialized", - }, - }) - - return { - event: async ({ event }: { event: Event }) => { - const cwd = directory || "" - - switch (event.type) { - case "session.created": { - const sessionId = event.properties.info.id - const body = buildPayload("session_start", sessionId, cwd, { - plugin_version: PLUGIN_VERSION, - }) - warpNotify(NOTIFICATION_TITLE, body) - return - } - - case "session.idle": { - const sessionId = event.properties.sessionID - - // Fetch the conversation to extract last query and response - // (port of on-stop.sh transcript parsing) - let query = "" - let response = "" - - if (sessionId) { - try { - const result = await client.session.messages({ - path: { id: sessionId }, - }) - const messages = result.data - - if (messages) { - const reversed = [...messages].reverse() - - const lastUser = reversed.find( - (m) => m.info.role === "user", - ) - if (lastUser) { - query = extractTextFromParts(lastUser.parts) + // Fire-and-forget the init log: awaiting client.app.log here can deadlock + // opencode's plugin loader on some versions (e.g. 1.4.8). + if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) { + client.app + .log({ + body: { + service: "opencode-warp", + level: "warn", + message: + "⚠️ Detected unsupported Warp version. Please update Warp to use this plugin.", + }, + }) + .catch((err) => { + console.error("[opencode-warp] failed to emit init log:", err) + }) + return {} + } + + client.app + .log({ + body: { + service: "opencode-warp", + level: "info", + message: "Warp plugin initialized", + }, + }) + .catch((err) => { + console.error("[opencode-warp] failed to emit init log:", err) + }) + + return { + event: async ({ event }: { event: Event }) => { + const cwd = directory || "" + + switch (event.type) { + case "session.created": { + const sessionId = event.properties.info.id + const body = buildPayload("session_start", sessionId, cwd, { + plugin_version: PLUGIN_VERSION, + }) + warpNotify(NOTIFICATION_TITLE, body) + return } - const lastAssistant = reversed.find( - (m) => m.info.role === "assistant", - ) - if (lastAssistant) { - response = extractTextFromParts(lastAssistant.parts) + case "session.idle": { + const sessionId = event.properties.sessionID + + // Fetch the conversation to extract last query and response + // (port of on-stop.sh transcript parsing) + let query = "" + let response = "" + + if (sessionId) { + try { + const result = await client.session.messages({ + path: { id: sessionId }, + }) + const messages = result.data + + if (messages) { + const reversed = [...messages].reverse() + + const lastUser = reversed.find( + (m) => m.info.role === "user", + ) + if (lastUser) { + query = extractTextFromParts(lastUser.parts) + } + + const lastAssistant = reversed.find( + (m) => m.info.role === "assistant", + ) + if (lastAssistant) { + response = extractTextFromParts(lastAssistant.parts) + } + } + } catch { + // If we can't fetch messages, send the notification without query/response + } + } + + const body = buildPayload("stop", sessionId, cwd, { + query: truncate(query, 200), + response: truncate(response, 200), + transcript_path: "", + }) + warpNotify(NOTIFICATION_TITLE, body) + return + } + + case "permission.updated": { + sendPermissionNotification(event.properties, cwd) + return + } + + case "permission.replied": { + const { sessionID, response } = event.properties + if (response === "reject") return + const body = buildPayload("permission_replied", sessionID, cwd) + warpNotify(NOTIFICATION_TITLE, body) + return + } + + default: { + // permission.asked is listed in the opencode docs but has no SDK type. + // Handle it with the same logic as permission.updated. + if ((event as any).type === "permission.asked") { + sendPermissionNotification((event as any).properties, cwd) + } } - } - } catch { - // If we can't fetch messages, send the notification without query/response } - } - - const body = buildPayload("stop", sessionId, cwd, { - query: truncate(query, 200), - response: truncate(response, 200), - transcript_path: "", - }) - warpNotify(NOTIFICATION_TITLE, body) - return - } - - case "permission.updated": { - sendPermissionNotification(event.properties, cwd) - return - } - - case "permission.replied": { - const { sessionID, response } = event.properties - if (response === "reject") return - const body = buildPayload("permission_replied", sessionID, cwd) - warpNotify(NOTIFICATION_TITLE, body) - return - } - - default: { - // permission.asked is listed in the opencode docs but has no SDK type. - // Handle it with the same logic as permission.updated. - if ((event as any).type === "permission.asked") { - sendPermissionNotification((event as any).properties, cwd) - } - } - } - }, - - // Fires once per new user message — used to send the prompt_submit hook. - // (We avoid the generic message.updated event because OpenCode fires it - // multiple times per message, and a late duplicate can clobber the - // completion notification.) - "chat.message": async (input, output) => { - const cwd = directory || "" - const queryText = extractTextFromParts(output.parts) - if (!queryText) return - - const body = buildPayload("prompt_submit", input.sessionID, cwd, { - query: truncate(queryText, 200), - }) - warpNotify(NOTIFICATION_TITLE, body) - }, - - // Fires before a tool executes — used to detect the built-in - // "question" tool so Warp can notify the user that input is needed. - "tool.execute.before": async (input) => { - if (input.tool !== "question") return - - const cwd = directory || "" - const body = buildPayload("question_asked", input.sessionID, cwd, { - tool_name: input.tool, - }) - warpNotify(NOTIFICATION_TITLE, body) - }, - - // Tool completion — fires after every tool call - "tool.execute.after": async (input) => { - const toolName = input.tool - const sessionId = input.sessionID - const cwd = directory || "" - - const body = buildPayload("tool_complete", sessionId, cwd, { - tool_name: toolName, - }) - warpNotify(NOTIFICATION_TITLE, body) - }, - } + }, + + // Fires once per new user message — used to send the prompt_submit hook. + // (We avoid the generic message.updated event because OpenCode fires it + // multiple times per message, and a late duplicate can clobber the + // completion notification.) + "chat.message": async (input, output) => { + const cwd = directory || "" + const queryText = extractTextFromParts(output.parts) + if (!queryText) return + + const body = buildPayload("prompt_submit", input.sessionID, cwd, { + query: truncate(queryText, 200), + }) + warpNotify(NOTIFICATION_TITLE, body) + }, + + // Fires before a tool executes — used to detect the built-in + // "question" tool so Warp can notify the user that input is needed. + "tool.execute.before": async (input) => { + if (input.tool !== "question") return + + const cwd = directory || "" + const body = buildPayload("question_asked", input.sessionID, cwd, { + tool_name: input.tool, + }) + warpNotify(NOTIFICATION_TITLE, body) + }, + + // Tool completion — fires after every tool call + "tool.execute.after": async (input) => { + const toolName = input.tool + const sessionId = input.sessionID + const cwd = directory || "" + + const body = buildPayload("tool_complete", sessionId, cwd, { + tool_name: toolName, + }) + warpNotify(NOTIFICATION_TITLE, body) + }, + } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f9a9264 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +import type { Part } from "@opencode-ai/sdk" + +export function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str + return str.slice(0, maxLen - 3) + "..." +} + +export function extractTextFromParts(parts: Part[]): string { + return parts + .filter((p): p is Part & { type: "text"; text: string } => + p.type === "text" && "text" in p && Boolean(p.text), + ) + .map((p) => p.text) + .join(" ") +} diff --git a/tests/index.test.ts b/tests/index.test.ts index b30a16b..e3628b5 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -import { truncate, extractTextFromParts } from "../src/index" +import { truncate, extractTextFromParts } from "../src/utils" import { buildPayload } from "../src/payload" describe("truncate", () => {