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
615 changes: 611 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@
"opencode": ">=1.3.13"
},
"dependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@opencode-ai/sdk": "^1.3.13",
"@opencode-ai/plugin": "^1.18",
"@opencode-ai/sdk": "^1.18",
"@opencode/plugin": "^2.0.10",
"bun-pty": "^0.4.10",
"open": "^11.0.0"
}
Expand Down
97 changes: 52 additions & 45 deletions src/plugin/pty/notification-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class NotificationManager implements SessionNotifier {
}

try {
const message = this.buildExitNotification(session, exitCode)
const message = buildExitNotification(session, exitCode)
let modelContext: {
model?: { providerID: string; modelID: string }
variant?: string
Expand Down Expand Up @@ -53,55 +53,62 @@ export class NotificationManager implements SessionNotifier {
// Ignore notification errors
}
}
}

private buildExitNotification(session: PTYSession, exitCode: number): string {
const lineCount = session.buffer.length
let lastLine = ''
if (lineCount > 0) {
for (let i = lineCount - 1; i >= 0; i--) {
const bufferLines = session.buffer.read(i, 1)
const line = bufferLines[0]
if (line !== undefined && line.trim() !== '') {
lastLine =
line.length > NOTIFICATION_LINE_TRUNCATE
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
: line
break
}
/**
* Builds the `<pty_exited>` notification text for a finished PTY session.
*
* Shared between the V1 notifier (`NotificationManager`, delivered via the
* SDK client's `promptAsync`) and the V2 notifier (delivered via the plugin
* context's `ctx.session.prompt`).
*/
export function buildExitNotification(session: PTYSession, exitCode: number): string {
const lineCount = session.buffer.length
let lastLine = ''
if (lineCount > 0) {
for (let i = lineCount - 1; i >= 0; i--) {
const bufferLines = session.buffer.read(i, 1)
const line = bufferLines[0]
if (line !== undefined && line.trim() !== '') {
lastLine =
line.length > NOTIFICATION_LINE_TRUNCATE
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
: line
break
}
}
}

const displayTitle = session.description ?? session.title
const truncatedTitle =
displayTitle.length > NOTIFICATION_TITLE_TRUNCATE
? `${displayTitle.slice(0, NOTIFICATION_TITLE_TRUNCATE)}...`
: displayTitle

const lines = [
'<pty_exited>',
`ID: ${session.id}`,
`Description: ${truncatedTitle}`,
`Exit Code: ${exitCode}`,
`TimeoutSeconds: ${session.timeoutSeconds ?? 'none'}`,
`Timed Out: ${session.timedOut ? 'yes' : 'no'}`,
`Output Lines: ${lineCount}`,
`Last Line: ${lastLine}`,
'</pty_exited>',
'',
]
const displayTitle = session.description ?? session.title
const truncatedTitle =
displayTitle.length > NOTIFICATION_TITLE_TRUNCATE
? `${displayTitle.slice(0, NOTIFICATION_TITLE_TRUNCATE)}...`
: displayTitle

if (session.timedOut) {
lines.push(
'Process reached its PTY timeout and was stopped automatically. Use pty_read to inspect the final output.'
)
} else if (exitCode === 0) {
lines.push('Use pty_read to check the full output.')
} else {
lines.push(
'Process failed. Use pty_read with the pattern parameter to search for errors in the output.'
)
}
const lines = [
'<pty_exited>',
`ID: ${session.id}`,
`Description: ${truncatedTitle}`,
`Exit Code: ${exitCode}`,
`TimeoutSeconds: ${session.timeoutSeconds ?? 'none'}`,
`Timed Out: ${session.timedOut ? 'yes' : 'no'}`,
`Output Lines: ${lineCount}`,
`Last Line: ${lastLine}`,
'</pty_exited>',
'',
]

return lines.join('\n')
if (session.timedOut) {
lines.push(
'Process reached its PTY timeout and was stopped automatically. Use pty_read to inspect the final output.'
)
} else if (exitCode === 0) {
lines.push('Use pty_read to check the full output.')
} else {
lines.push(
'Process failed. Use pty_read with the pattern parameter to search for errors in the output.'
)
}

return lines.join('\n')
}
43 changes: 27 additions & 16 deletions src/v2/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,32 @@ export async function handleShowServerUrlCommand(options?: ServerOptions): Promi
return `PTY Sessions Web Interface URL: ${server.server.url.origin}`
}

export function registerV2Commands(draft: CommandDraft, _options?: OpencodePtyOptions): void {
if (typeof draft.update === 'function') {
draft.update(PTY_OPEN_CLIENT_COMMAND, (cmd) => {
if (cmd) {
cmd.description = 'Open PTY Sessions Web Interface'
cmd.template =
'This command will start the PTY Sessions Web Interface in your default browser.'
}
})

draft.update(PTY_SHOW_SERVER_URL_COMMAND, (cmd) => {
if (cmd) {
cmd.description = 'Show PTY Sessions Web Interface URL'
cmd.template = 'This command will show the PTY Sessions Web Interface URL.'
}
})
/**
* Registers the PTY slash commands with opencode v2's `CommandEditor`.
*
* opencode v2's `command.transform` draft exposes `add(definition)` only
* (there is no `update`), so commands must be created with an `execute`
* handler rather than "updated".
*/
export function registerV2Commands(draft: CommandDraft, options?: OpencodePtyOptions): void {
if (typeof draft.add !== 'function') {
return
}
const add = draft.add.bind(draft)

add({
name: PTY_OPEN_CLIENT_COMMAND,
description: 'Open PTY Sessions Web Interface',
execute: async () => {
await handleOpenClientCommand(options)
},
})

add({
name: PTY_SHOW_SERVER_URL_COMMAND,
description: 'Show PTY Sessions Web Interface URL',
execute: async () => {
await handleShowServerUrlCommand(options)
},
})
}
35 changes: 28 additions & 7 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createV2Adapter } from '../adapters/v2/index.ts'
import { installHostAdapter } from '../adapters/index.ts'
import { getOrCreateServer, registerV2Commands } from './commands.ts'
import { define, type PluginContextV2, type PluginV2 } from './types.ts'
import { V2SessionNotifier } from './notifier.ts'
import { registerV2Tools } from './tools.ts'
import { define, type OpencodePtyOptions, type PluginContextV2, type PluginV2 } from './types.ts'

export * from './commands.ts'
export * from './notifier.ts'
export * from './tools.ts'
export * from './types.ts'

Expand All @@ -14,20 +17,38 @@ export * from './types.ts'
export const Plugin: PluginV2 = define({
id: 'opencode-pty',
setup: async (ctx: PluginContextV2) => {
const options = ctx.options
const adapter = createV2Adapter()
// opencode v2 plugin contexts are server clients: `ctx.session.prompt`
// wakes a session with a user prompt, preserving the session's current
// model by construction. Pre-2.0 hosts without the session domain still
// load the plugin, but exit notifications are disabled with a visible
// warning instead of silently never arriving.
const notifier =
typeof ctx.session?.prompt === 'function' ? new V2SessionNotifier(ctx.session) : undefined
if (!notifier) {
console.warn(
'[opencode-pty] host does not expose ctx.session.prompt — exit notifications disabled'
)
}

const adapter = createV2Adapter({ notifier })
installHostAdapter(adapter)

if (ctx.tool && typeof ctx.tool.transform === 'function') {
await ctx.tool.transform((draft) => {
registerV2Tools(draft)
})
}

if (ctx.command && typeof ctx.command.transform === 'function') {
await ctx.command.transform((draft) => {
registerV2Commands(draft, options)
registerV2Commands(draft, ctx.options as OpencodePtyOptions | undefined)
})
}

if (options?.autostart) {
if (ctx.options?.autostart) {
await getOrCreateServer({
port: options.port,
hostname: options.hostname,
port: ctx.options.port,
hostname: ctx.options.hostname,
})
}
},
Expand Down
54 changes: 54 additions & 0 deletions src/v2/notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Plugin } from '@opencode/plugin'
import type { SessionNotifier } from '../adapters/types.ts'
import { buildExitNotification } from '../plugin/pty/notification-manager.ts'
import type { PTYSession } from '../plugin/pty/types.ts'

/**
* The subset of opencode v2's plugin `session` domain we use to wake a session.
*
* The full domain (`Plugin.Context["session"]`) also exposes `get`,
* `switchModel`, `wait`, `context` and more; keeping the notifier bound to
* `prompt` only makes it easy to construct in tests and resilient to hosts
* that only expose part of the surface.
*/
export type V2SessionPrompt = Pick<Plugin.Context['session'], 'prompt'>

/**
* Delivers `<pty_exited>` notifications through opencode v2's plugin context.
*
* The notification is admitted as a user prompt with a deterministic message
* id (`pty_<id>_exited`), so `admission.reconcile` makes repeated deliveries
* idempotent (e.g. when a kill races the process exit). Default delivery
* (`steer`) plus `execution.wake` starts a new agent turn, and opencode
* resolves the run with the session's *current* model — so the user's model
* selection is preserved without the explicit model lookup the V1 notifier
* needs (`session/runner/model.ts` has no `agent.model` fallback like the
* V1 `setAgentModel` chain did).
*
* Delivery failures are logged instead of swallowed, so a dead wake-up is
* never indistinguishable from "the model decided not to respond".
*/
export class V2SessionNotifier implements SessionNotifier {
constructor(private readonly session: V2SessionPrompt) {}

async sendExitNotification(session: PTYSession, exitCode: number): Promise<void> {
if (!session.parentSessionId) {
console.warn(`[opencode-pty] cannot notify: session ${session.id} has no parent session`)
return
}

const text = buildExitNotification(session, exitCode)
try {
await this.session.prompt({
sessionID: session.parentSessionId,
id: `pty_${session.id}_exited`,
text,
})
} catch (error) {
console.error(
`[opencode-pty] failed to deliver exit notification for ${session.id}:`,
error instanceof Error ? error.message : String(error)
)
}
}
}
38 changes: 38 additions & 0 deletions src/v2/tools.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { tool } from '@opencode-ai/plugin'
import { ptyKill } from '../plugin/pty/tools/kill.ts'
import { ptyList } from '../plugin/pty/tools/list.ts'
import { ptyRead } from '../plugin/pty/tools/read.ts'
import { ptySpawn } from '../plugin/pty/tools/spawn.ts'
import { ptyWrite } from '../plugin/pty/tools/write.ts'
import type { ToolDraft, ToolInfoV2 } from './types.ts'

export const ptyTools = {
pty_spawn: ptySpawn,
Expand All @@ -13,3 +15,39 @@ export const ptyTools = {
} as const

export type PTYToolName = keyof typeof ptyTools

type V1ToolDefinition = {
description: string
args: Record<string, unknown>
execute: (args: never, context: never) => Promise<string>
}

/**
* Registers the PTY tools with opencode v2's `ToolEditor`.
*
* The tool definitions are authored against the V1 `tool()` helper
* (`{ description, args, execute }`). opencode v2 expects `Tool.Info`
* (`{ name, input, description, execute }`); we adapt:
* - `args` (Zod raw shape) -> `input`: JSON Schema (Zod v4 `toJSONSchema`)
* - string result -> `{ content }`
*/
export function registerV2Tools(draft: ToolDraft): void {
if (typeof draft.add !== 'function') {
return
}
const add = draft.add.bind(draft)
const tools = ptyTools as unknown as Record<string, V1ToolDefinition>

for (const [name, definition] of Object.entries(tools)) {
const info: ToolInfoV2 = {
name,
description: definition.description,
input: tool.schema.toJSONSchema(tool.schema.object(definition.args)),
execute: async (input, context) => {
const result = await definition.execute(input as never, context as never)
return typeof result === 'string' ? { content: result } : result
},
}
add(info)
}
}
Loading
Loading