Skip to content

Commit 9be5126

Browse files
authored
fix(v2): register pty tools, slash commands and exit notifications via the V2 plugin SDK (#61)
* fix(v2): register pty tools and slash commands via editor add() The V2 setup registered neither tools nor commands with OpenCode: - Tools: `ptyTools` was exported but never passed to `ctx.tool.transform`, so no `pty_*` tool ever reached OpenCode. Issue #55 explicitly asked to "register PTY tools through the V2 tool transform/registration surface". - Commands: `registerV2Commands` called `draft.update(name, cb)`, but OpenCode v2's CommandEditor exposes `add(definition)` only. The guard `typeof draft.update === 'function'` therefore always short-circuited and the slash commands never appeared in the "/" menu. Adapts the V1 tool definitions ({ description, args, execute }) to V2 Tool.Info ({ name, input, description, execute }), registers them through ctx.tool.transform, and switches commands to draft.add() with an execute handler. Tests now assert real registration (mocking add()) instead of mere module-level existence. * refactor(v2): derive tool input schemas as JSON Schema opencode v2's Tool.Info expects a JSON Schema for `input` (see the plugins docs), not a Zod schema. Convert the V1 Zod args via Zod v4's `toJSONSchema` instead of passing the Zod object through. * style(v2): collapse execute call to satisfy biome format check The PR CI (test (format), Devenv CI) failed because biome wants the definition.execute(...) call on a single line. * chore(deps): modernize plugin SDK to @opencode/plugin 2 and @opencode-ai/* 1.18 - add @opencode/plugin@^2.0.10 (V2 plugin SDK: Plugin.Context with the session domain, used to type ctx.session in src/v2) - bump @opencode-ai/plugin + @opencode-ai/sdk from ^1.3.13 to ^1.18 (kept for the V1 entry point); ToolResult is now string | { output }, adjusted the spawn integration test accordingly * fix(v2): deliver exit notifications via ctx.session.prompt The V2 entry point never wired a SessionNotifier: createV2Adapter() was called without options, so installHostAdapter skipped setNotifier and the fallback NotificationManager had no client - <pty_exited> was silently never sent, leaving agents that were told to wait for it stuck. - src/v2/notifier.ts: V2SessionNotifier delivers through the plugin context's session domain (ctx.session.prompt) with a deterministic message id (pty_<id>_exited) so admission.reconcile makes repeated deliveries idempotent. opencode v2 resolves the run with the session's current model, so the V1 setAgentModel clobber (#57) does not apply. - src/v2/index.ts: build the notifier from ctx in setup, guarded by typeof ctx.session?.prompt; hosts without the session domain get a visible warning instead of dead silence (#54) - src/plugin/pty/notification-manager.ts: export buildExitNotification so both notifiers share the message format - tests: live integration covers notification delivery + the no-session fallback
1 parent ce5f61c commit 9be5126

11 files changed

Lines changed: 1042 additions & 106 deletions

File tree

‎bun.lock‎

Lines changed: 611 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,9 @@
103103
"opencode": ">=1.3.13"
104104
},
105105
"dependencies": {
106-
"@opencode-ai/plugin": "^1.3.13",
107-
"@opencode-ai/sdk": "^1.3.13",
106+
"@opencode-ai/plugin": "^1.18",
107+
"@opencode-ai/sdk": "^1.18",
108+
"@opencode/plugin": "^2.0.10",
108109
"bun-pty": "^0.4.10",
109110
"open": "^11.0.0"
110111
}

‎src/plugin/pty/notification-manager.ts‎

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class NotificationManager implements SessionNotifier {
1616
}
1717

1818
try {
19-
const message = this.buildExitNotification(session, exitCode)
19+
const message = buildExitNotification(session, exitCode)
2020
let modelContext: {
2121
model?: { providerID: string; modelID: string }
2222
variant?: string
@@ -53,55 +53,62 @@ export class NotificationManager implements SessionNotifier {
5353
// Ignore notification errors
5454
}
5555
}
56+
}
5657

57-
private buildExitNotification(session: PTYSession, exitCode: number): string {
58-
const lineCount = session.buffer.length
59-
let lastLine = ''
60-
if (lineCount > 0) {
61-
for (let i = lineCount - 1; i >= 0; i--) {
62-
const bufferLines = session.buffer.read(i, 1)
63-
const line = bufferLines[0]
64-
if (line !== undefined && line.trim() !== '') {
65-
lastLine =
66-
line.length > NOTIFICATION_LINE_TRUNCATE
67-
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
68-
: line
69-
break
70-
}
58+
/**
59+
* Builds the `<pty_exited>` notification text for a finished PTY session.
60+
*
61+
* Shared between the V1 notifier (`NotificationManager`, delivered via the
62+
* SDK client's `promptAsync`) and the V2 notifier (delivered via the plugin
63+
* context's `ctx.session.prompt`).
64+
*/
65+
export function buildExitNotification(session: PTYSession, exitCode: number): string {
66+
const lineCount = session.buffer.length
67+
let lastLine = ''
68+
if (lineCount > 0) {
69+
for (let i = lineCount - 1; i >= 0; i--) {
70+
const bufferLines = session.buffer.read(i, 1)
71+
const line = bufferLines[0]
72+
if (line !== undefined && line.trim() !== '') {
73+
lastLine =
74+
line.length > NOTIFICATION_LINE_TRUNCATE
75+
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
76+
: line
77+
break
7178
}
7279
}
80+
}
7381

74-
const displayTitle = session.description ?? session.title
75-
const truncatedTitle =
76-
displayTitle.length > NOTIFICATION_TITLE_TRUNCATE
77-
? `${displayTitle.slice(0, NOTIFICATION_TITLE_TRUNCATE)}...`
78-
: displayTitle
79-
80-
const lines = [
81-
'<pty_exited>',
82-
`ID: ${session.id}`,
83-
`Description: ${truncatedTitle}`,
84-
`Exit Code: ${exitCode}`,
85-
`TimeoutSeconds: ${session.timeoutSeconds ?? 'none'}`,
86-
`Timed Out: ${session.timedOut ? 'yes' : 'no'}`,
87-
`Output Lines: ${lineCount}`,
88-
`Last Line: ${lastLine}`,
89-
'</pty_exited>',
90-
'',
91-
]
82+
const displayTitle = session.description ?? session.title
83+
const truncatedTitle =
84+
displayTitle.length > NOTIFICATION_TITLE_TRUNCATE
85+
? `${displayTitle.slice(0, NOTIFICATION_TITLE_TRUNCATE)}...`
86+
: displayTitle
9287

93-
if (session.timedOut) {
94-
lines.push(
95-
'Process reached its PTY timeout and was stopped automatically. Use pty_read to inspect the final output.'
96-
)
97-
} else if (exitCode === 0) {
98-
lines.push('Use pty_read to check the full output.')
99-
} else {
100-
lines.push(
101-
'Process failed. Use pty_read with the pattern parameter to search for errors in the output.'
102-
)
103-
}
88+
const lines = [
89+
'<pty_exited>',
90+
`ID: ${session.id}`,
91+
`Description: ${truncatedTitle}`,
92+
`Exit Code: ${exitCode}`,
93+
`TimeoutSeconds: ${session.timeoutSeconds ?? 'none'}`,
94+
`Timed Out: ${session.timedOut ? 'yes' : 'no'}`,
95+
`Output Lines: ${lineCount}`,
96+
`Last Line: ${lastLine}`,
97+
'</pty_exited>',
98+
'',
99+
]
104100

105-
return lines.join('\n')
101+
if (session.timedOut) {
102+
lines.push(
103+
'Process reached its PTY timeout and was stopped automatically. Use pty_read to inspect the final output.'
104+
)
105+
} else if (exitCode === 0) {
106+
lines.push('Use pty_read to check the full output.')
107+
} else {
108+
lines.push(
109+
'Process failed. Use pty_read with the pattern parameter to search for errors in the output.'
110+
)
106111
}
112+
113+
return lines.join('\n')
107114
}

‎src/v2/commands.ts‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,32 @@ export async function handleShowServerUrlCommand(options?: ServerOptions): Promi
3737
return `PTY Sessions Web Interface URL: ${server.server.url.origin}`
3838
}
3939

40-
export function registerV2Commands(draft: CommandDraft, _options?: OpencodePtyOptions): void {
41-
if (typeof draft.update === 'function') {
42-
draft.update(PTY_OPEN_CLIENT_COMMAND, (cmd) => {
43-
if (cmd) {
44-
cmd.description = 'Open PTY Sessions Web Interface'
45-
cmd.template =
46-
'This command will start the PTY Sessions Web Interface in your default browser.'
47-
}
48-
})
49-
50-
draft.update(PTY_SHOW_SERVER_URL_COMMAND, (cmd) => {
51-
if (cmd) {
52-
cmd.description = 'Show PTY Sessions Web Interface URL'
53-
cmd.template = 'This command will show the PTY Sessions Web Interface URL.'
54-
}
55-
})
40+
/**
41+
* Registers the PTY slash commands with opencode v2's `CommandEditor`.
42+
*
43+
* opencode v2's `command.transform` draft exposes `add(definition)` only
44+
* (there is no `update`), so commands must be created with an `execute`
45+
* handler rather than "updated".
46+
*/
47+
export function registerV2Commands(draft: CommandDraft, options?: OpencodePtyOptions): void {
48+
if (typeof draft.add !== 'function') {
49+
return
5650
}
51+
const add = draft.add.bind(draft)
52+
53+
add({
54+
name: PTY_OPEN_CLIENT_COMMAND,
55+
description: 'Open PTY Sessions Web Interface',
56+
execute: async () => {
57+
await handleOpenClientCommand(options)
58+
},
59+
})
60+
61+
add({
62+
name: PTY_SHOW_SERVER_URL_COMMAND,
63+
description: 'Show PTY Sessions Web Interface URL',
64+
execute: async () => {
65+
await handleShowServerUrlCommand(options)
66+
},
67+
})
5768
}

‎src/v2/index.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { createV2Adapter } from '../adapters/v2/index.ts'
22
import { installHostAdapter } from '../adapters/index.ts'
33
import { getOrCreateServer, registerV2Commands } from './commands.ts'
4-
import { define, type PluginContextV2, type PluginV2 } from './types.ts'
4+
import { V2SessionNotifier } from './notifier.ts'
5+
import { registerV2Tools } from './tools.ts'
6+
import { define, type OpencodePtyOptions, type PluginContextV2, type PluginV2 } from './types.ts'
57

68
export * from './commands.ts'
9+
export * from './notifier.ts'
710
export * from './tools.ts'
811
export * from './types.ts'
912

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

36+
if (ctx.tool && typeof ctx.tool.transform === 'function') {
37+
await ctx.tool.transform((draft) => {
38+
registerV2Tools(draft)
39+
})
40+
}
41+
2142
if (ctx.command && typeof ctx.command.transform === 'function') {
2243
await ctx.command.transform((draft) => {
23-
registerV2Commands(draft, options)
44+
registerV2Commands(draft, ctx.options as OpencodePtyOptions | undefined)
2445
})
2546
}
2647

27-
if (options?.autostart) {
48+
if (ctx.options?.autostart) {
2849
await getOrCreateServer({
29-
port: options.port,
30-
hostname: options.hostname,
50+
port: ctx.options.port,
51+
hostname: ctx.options.hostname,
3152
})
3253
}
3354
},

‎src/v2/notifier.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Plugin } from '@opencode/plugin'
2+
import type { SessionNotifier } from '../adapters/types.ts'
3+
import { buildExitNotification } from '../plugin/pty/notification-manager.ts'
4+
import type { PTYSession } from '../plugin/pty/types.ts'
5+
6+
/**
7+
* The subset of opencode v2's plugin `session` domain we use to wake a session.
8+
*
9+
* The full domain (`Plugin.Context["session"]`) also exposes `get`,
10+
* `switchModel`, `wait`, `context` and more; keeping the notifier bound to
11+
* `prompt` only makes it easy to construct in tests and resilient to hosts
12+
* that only expose part of the surface.
13+
*/
14+
export type V2SessionPrompt = Pick<Plugin.Context['session'], 'prompt'>
15+
16+
/**
17+
* Delivers `<pty_exited>` notifications through opencode v2's plugin context.
18+
*
19+
* The notification is admitted as a user prompt with a deterministic message
20+
* id (`pty_<id>_exited`), so `admission.reconcile` makes repeated deliveries
21+
* idempotent (e.g. when a kill races the process exit). Default delivery
22+
* (`steer`) plus `execution.wake` starts a new agent turn, and opencode
23+
* resolves the run with the session's *current* model — so the user's model
24+
* selection is preserved without the explicit model lookup the V1 notifier
25+
* needs (`session/runner/model.ts` has no `agent.model` fallback like the
26+
* V1 `setAgentModel` chain did).
27+
*
28+
* Delivery failures are logged instead of swallowed, so a dead wake-up is
29+
* never indistinguishable from "the model decided not to respond".
30+
*/
31+
export class V2SessionNotifier implements SessionNotifier {
32+
constructor(private readonly session: V2SessionPrompt) {}
33+
34+
async sendExitNotification(session: PTYSession, exitCode: number): Promise<void> {
35+
if (!session.parentSessionId) {
36+
console.warn(`[opencode-pty] cannot notify: session ${session.id} has no parent session`)
37+
return
38+
}
39+
40+
const text = buildExitNotification(session, exitCode)
41+
try {
42+
await this.session.prompt({
43+
sessionID: session.parentSessionId,
44+
id: `pty_${session.id}_exited`,
45+
text,
46+
})
47+
} catch (error) {
48+
console.error(
49+
`[opencode-pty] failed to deliver exit notification for ${session.id}:`,
50+
error instanceof Error ? error.message : String(error)
51+
)
52+
}
53+
}
54+
}

‎src/v2/tools.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { tool } from '@opencode-ai/plugin'
12
import { ptyKill } from '../plugin/pty/tools/kill.ts'
23
import { ptyList } from '../plugin/pty/tools/list.ts'
34
import { ptyRead } from '../plugin/pty/tools/read.ts'
45
import { ptySpawn } from '../plugin/pty/tools/spawn.ts'
56
import { ptyWrite } from '../plugin/pty/tools/write.ts'
7+
import type { ToolDraft, ToolInfoV2 } from './types.ts'
68

79
export const ptyTools = {
810
pty_spawn: ptySpawn,
@@ -13,3 +15,39 @@ export const ptyTools = {
1315
} as const
1416

1517
export type PTYToolName = keyof typeof ptyTools
18+
19+
type V1ToolDefinition = {
20+
description: string
21+
args: Record<string, unknown>
22+
execute: (args: never, context: never) => Promise<string>
23+
}
24+
25+
/**
26+
* Registers the PTY tools with opencode v2's `ToolEditor`.
27+
*
28+
* The tool definitions are authored against the V1 `tool()` helper
29+
* (`{ description, args, execute }`). opencode v2 expects `Tool.Info`
30+
* (`{ name, input, description, execute }`); we adapt:
31+
* - `args` (Zod raw shape) -> `input`: JSON Schema (Zod v4 `toJSONSchema`)
32+
* - string result -> `{ content }`
33+
*/
34+
export function registerV2Tools(draft: ToolDraft): void {
35+
if (typeof draft.add !== 'function') {
36+
return
37+
}
38+
const add = draft.add.bind(draft)
39+
const tools = ptyTools as unknown as Record<string, V1ToolDefinition>
40+
41+
for (const [name, definition] of Object.entries(tools)) {
42+
const info: ToolInfoV2 = {
43+
name,
44+
description: definition.description,
45+
input: tool.schema.toJSONSchema(tool.schema.object(definition.args)),
46+
execute: async (input, context) => {
47+
const result = await definition.execute(input as never, context as never)
48+
return typeof result === 'string' ? { content: result } : result
49+
},
50+
}
51+
add(info)
52+
}
53+
}

0 commit comments

Comments
 (0)