pantheon-opencode 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,21 +98,37 @@ export function runHook(
98
98
  const scriptPath = path.join(opts.cwd ?? resolveHooksDir(), script)
99
99
  const timeout = opts.timeout ?? HOOK_TIMEOUT_MS
100
100
 
101
+ let serializedPayload: string
102
+ try {
103
+ serializedPayload = JSON.stringify(payload)
104
+ } catch (err) {
105
+ // Fail closed: a scanner hook must not run without the payload it is
106
+ // supposed to inspect, and serialization errors must never reject.
107
+ return Promise.resolve({
108
+ code: 1,
109
+ stdout: '',
110
+ stderr: `payload serialization failed: ${err instanceof Error ? err.message : String(err)}`,
111
+ signal: null,
112
+ timedOut: false,
113
+ })
114
+ }
115
+
101
116
  return new Promise<HookResult>((resolve) => {
102
117
  const stdoutBuf: { value: string } = { value: '' }
103
118
  const stderrBuf: { value: string } = { value: '' }
104
119
  let settled = false
120
+ let timedOut = false
121
+ let timeoutHandle: NodeJS.Timeout | undefined
105
122
 
106
123
  const settle = (result: HookResult) => {
107
124
  if (!settled) {
108
125
  settled = true
126
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
109
127
  resolve(result)
110
128
  }
111
129
  }
112
130
 
113
131
  const spawnOpts = {
114
- timeout,
115
- killSignal: 'SIGKILL' as const,
116
132
  env: hookEnv(payload, opts.env),
117
133
  // stdio:'pipe' is equivalent to ['pipe','pipe','pipe'] — it guarantees
118
134
  // the child's stdin is OUR pipe, NEVER the opencode TUI's terminal
@@ -120,6 +136,10 @@ export function runHook(
120
136
  // the opencode core hook timeout).
121
137
  stdio: 'pipe' as const,
122
138
  windowsHide: true,
139
+ // A process group lets the timeout kill shell descendants too. Without
140
+ // this, a shell hook can leave `sleep` holding stdout/stderr open after
141
+ // the shell itself receives SIGKILL, so the Promise would not settle.
142
+ ...(process.platform !== 'win32' ? { detached: true } : {}),
123
143
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
124
144
  }
125
145
 
@@ -172,15 +192,35 @@ export function runHook(
172
192
  stdout: stdoutBuf.value,
173
193
  stderr: stderrBuf.value,
174
194
  signal,
175
- timedOut: code === null && signal === 'SIGKILL',
195
+ timedOut,
176
196
  })
177
197
  })
178
198
 
199
+ timeoutHandle = setTimeout(() => {
200
+ timedOut = true
201
+ try {
202
+ // Negative PIDs target the process group created by `detached`.
203
+ if (process.platform !== 'win32' && child.pid !== undefined) {
204
+ process.kill(-child.pid, 'SIGKILL')
205
+ } else {
206
+ child.kill('SIGKILL')
207
+ }
208
+ } catch {
209
+ // The child may have exited between the timer and kill call. The
210
+ // close/error handlers still produce the structured result.
211
+ try {
212
+ child.kill('SIGKILL')
213
+ } catch {
214
+ // Hooks must never leak a kill failure to the plugin host.
215
+ }
216
+ }
217
+ }, timeout)
218
+
179
219
  // Deliver the Claude Code protocol JSON on stdin. stdin.end() runs
180
220
  // UNCONDITIONALLY — even for an empty payload {} or a script that never
181
221
  // reads stdin — so the child can never block waiting for input and hit
182
222
  // the runtime hook timeout (reported as "Hook X timed out after 30000ms").
183
- child.stdin.write(JSON.stringify(payload))
223
+ child.stdin.write(serializedPayload)
184
224
  child.stdin.end()
185
225
  })
186
226
  }