mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
5c598a18d3
* feat(plugin): no event monitor at session start; an opted-in session's watch starts from the mod, and an unarmed one shows nothing (TASK-3513) Every session with the plugin opened with "Monitor ... ended without producing output", and Claude explaining it: the "always" monitor ran pad-monitor.sh, which exits when the session has not consented, and Claude Code reports any monitor that exits. Monitors take only "always" or "on-skill-invoke", with no condition and no silent exit. The plugin is already a mod (the pane). Its session.start now: - records presence (`pad session register`), silently, as the monitor did; - asks `pad session should-arm`, PLAN-2613's gate, unchanged (.pad.toml push.auto_arm stays the per-project opt-in); - only when armed, spawns pad-monitor.sh ($.process.spawn) and relays its stdout lines with $.prompt.submit, coalescing a burst into one message per idle window. An unarmed session runs nothing and shows nothing: no process, no line, no notice. session.end stops the stream, except /clear and /resume, which keep the process, the socket consent is keyed on, and do not refire session.start. Measured (Claude Code 2.1.295): CLAUDE_CODE_MESSAGING_SOCKET reaches a mod's processes, so consent and the wrapper's lockfile key match the connect monitor's; CLAUDE_PID and CLAUDECODE do not, so the mod passes PAD_SESSION_PID (its children's parent is the session process) and CLAUDECODE=1. - monitors.json: pad-events-auto removed; /pad:connect keeps its on-skill-invoke monitor, the explicit path and the path where mods do not load. The lockfile still keeps the two from streaming twice. - pad-monitor.sh: runs the stream as a child the traps can name, so a caller that stops the script stops the stream too. - SKILL.md and README: no "always-on wrapper"; the two delivery forms. Tests (claude plugin test plugin, 18 pass, the pane's included): unarmed registers and starts/shows nothing; no CLI; the CLI calls carry the session pid; armed relays one message per burst (stderr dropped); survives /clear and /resume; any other end relays nothing after it. Controls: dropping the gate fails 2, ending on /clear fails 1, and the relay's ownership check was found by its test (a late chunk relayed). Hand-check in an interactive session (tmux, --plugin-dir): unarmed start shows nothing and registers with the right pid; armed start spawns the wrapper and stream under the session process; a submitted line renders as "The pad plugin sent a message:"; the stream survives /clear; /exit takes the wrapper, the stream and the lockfile with it. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(plugin): a session that ends while the CLI answers starts no watch (TASK-3513, codex r1) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(plugin): a flush the session's end overtook relays nothing more (TASK-3513, codex r2) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(plugin): pad-monitor.sh frees its lock only once the stream has closed (TASK-3513, codex r3) Measured with a stand-in pad whose stream takes 1s to close on TERM: the lock stays held through the close and is gone after, with no process left. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(plugin): a burst of watch lines goes to Claude after it goes quiet, capped at 10s (TASK-3513, codex r4) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import { expect, mock, test } from 'claude-code/testing'
|
|
|
|
// TASK-3513: live item events start from the mod, only for a session that has
|
|
// consented (PLAN-2613's `pad session should-arm`), and an unarmed session runs
|
|
// nothing and shows nothing. These tests stand in for the CLI and the stream.
|
|
|
|
type Opts = { armed?: boolean; noCli?: boolean; chunks?: { stream: string; text: string }[]; gate?: Promise<void>; holdSubmit?: boolean }
|
|
|
|
function stubs(on: any, opts: Opts = {}) {
|
|
const runs: { argv: string[]; env: Record<string, string> }[] = []
|
|
const spawns: { argv: string[]; env: Record<string, string> }[] = []
|
|
const submitted: string[] = []
|
|
const shown: string[] = []
|
|
const stream = { closed: false, release: () => {} }
|
|
on('session.start', ($: any, e: any) => ({ cwd: e.cwd }))
|
|
on('session.end', () => ({ sessionId: 's1' }))
|
|
on('command.register', () => ({ value: undefined }))
|
|
on('store.get', () => ({ value: undefined }))
|
|
on('ui.panes', () => ({ value: [] }))
|
|
for (const name of ['ui.log', 'ui.toast', 'ui.status', 'ui.notice']) {
|
|
on(name, ($: any, e: any) => { shown.push(name + ': ' + String(e.text)); return { value: undefined } })
|
|
}
|
|
on('process.run', ($: any, e: any) => {
|
|
runs.push({ argv: [...e.argv], env: { ...(e.init?.env || {}) } })
|
|
if (e.argv[0] === 'sh') return { value: { exitCode: 0, stdout: '4242\n', stderr: '' } }
|
|
if (opts.noCli) return { deny: 'spawn pad ENOENT' }
|
|
if (e.argv.join(' ') === 'pad session should-arm') {
|
|
const answer = { value: { exitCode: opts.armed ? 0 : 1, stdout: '', stderr: '' } }
|
|
return opts.gate ? opts.gate.then(() => answer) : answer
|
|
}
|
|
return { value: { exitCode: 0, stdout: '', stderr: '' } }
|
|
})
|
|
on('process.spawn', async function* ($: any, e: any) {
|
|
spawns.push({ argv: [...e.argv], env: { ...(e.env || {}) } })
|
|
try {
|
|
for (const c of opts.chunks || []) yield c
|
|
// The stream stays open; each release() makes it write one more line.
|
|
// An iterator the mod ended stops at that yield (an async generator's
|
|
// return() takes effect at its next yield; the engine kills the child).
|
|
for (;;) {
|
|
await new Promise<void>((r) => { stream.release = r })
|
|
yield { stream: 'stdout', text: 'late line\n' }
|
|
}
|
|
} finally {
|
|
stream.closed = true
|
|
}
|
|
})
|
|
const held = { release: () => {} }
|
|
on('prompt.submit', ($: any, e: any) => {
|
|
submitted.push(e.text)
|
|
if (!opts.holdSubmit) return { text: e.text }
|
|
return new Promise((r) => { held.release = () => r({ text: e.text }) })
|
|
})
|
|
return { runs, spawns, submitted, shown, stream, held }
|
|
}
|
|
|
|
const settle = () => new Promise((r) => setTimeout(r, 30))
|
|
|
|
test('an unarmed session registers its presence and starts and shows nothing', async ($, on) => {
|
|
mock.clock(on)
|
|
const s = stubs(on, { armed: false })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
expect(s.runs.map((r) => r.argv.join(' '))).toContain('pad session register')
|
|
expect(s.runs.map((r) => r.argv.join(' '))).toContain('pad session should-arm')
|
|
expect(s.spawns).toEqual([])
|
|
expect(s.submitted).toEqual([])
|
|
expect(s.shown).toEqual([])
|
|
})
|
|
|
|
test('without the pad CLI nothing starts and nothing shows', async ($, on) => {
|
|
mock.clock(on)
|
|
const s = stubs(on, { noCli: true })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
expect(s.spawns).toEqual([])
|
|
expect(s.shown).toEqual([])
|
|
})
|
|
|
|
test('the CLI calls name the harness session, which Claude Code does not export to a mod', async ($, on) => {
|
|
mock.clock(on)
|
|
const s = stubs(on, { armed: true })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
const pad = s.runs.filter((r) => r.argv[0] === 'pad')
|
|
expect(pad.length).toBe(2)
|
|
for (const r of pad) expect(r.env).toEqual({ CLAUDECODE: '1', PAD_SESSION_PID: '4242' })
|
|
expect(s.spawns[0].env.PAD_SESSION_PID).toBe('4242')
|
|
})
|
|
|
|
test('an armed session starts the gated wrapper and relays its lines, one message per burst', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const s = stubs(on, {
|
|
armed: true,
|
|
chunks: [
|
|
{ stream: 'stdout', text: 'TASK-7 changed: status done\nTASK-8 ' },
|
|
{ stream: 'stderr', text: 'reconnecting\n' },
|
|
{ stream: 'stdout', text: 'pushed: look at this\n' },
|
|
],
|
|
})
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
expect(s.spawns.length).toBe(1)
|
|
expect(s.spawns[0].argv[0]).toMatch(/\/scripts\/pad-monitor\.sh$/)
|
|
expect(s.submitted).toEqual([])
|
|
await clock.advance(1500)
|
|
await settle()
|
|
expect(s.submitted).toEqual(['TASK-7 changed: status done\nTASK-8 pushed: look at this'])
|
|
expect(s.shown).toEqual([])
|
|
})
|
|
|
|
test('the stream survives /clear and /resume', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const s = stubs(on, { armed: true })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
await $.session.end({ reason: 'clear' })
|
|
await $.session.end({ reason: 'resume' })
|
|
s.stream.release()
|
|
await settle()
|
|
await clock.advance(1500)
|
|
await settle()
|
|
expect(s.stream.closed).toBe(false)
|
|
expect(s.submitted).toEqual(['late line'])
|
|
})
|
|
|
|
test('any other end stops the stream: nothing after it is relayed', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const s = stubs(on, { armed: true })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
await $.session.end({ reason: 'prompt_input_exit' })
|
|
s.stream.release()
|
|
await settle()
|
|
await clock.advance(1500)
|
|
await settle()
|
|
expect(s.stream.closed).toBe(true)
|
|
expect(s.submitted).toEqual([])
|
|
})
|
|
|
|
test('a session that ends while the CLI is still answering starts nothing', async ($, on) => {
|
|
mock.clock(on)
|
|
let release = () => {}
|
|
const gate = new Promise<void>((r) => { release = r })
|
|
const s = stubs(on, { armed: true, gate })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await $.session.end({ reason: 'prompt_input_exit' })
|
|
release()
|
|
await settle()
|
|
expect(s.spawns).toEqual([])
|
|
})
|
|
|
|
test('lines read after the session ended are not relayed by a flush already waiting', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const s = stubs(on, { armed: true, holdSubmit: true, chunks: [{ stream: 'stdout', text: 'first\n' }] })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
await clock.advance(1500)
|
|
await settle()
|
|
expect(s.submitted).toEqual(['first'])
|
|
await $.session.end({ reason: 'prompt_input_exit' })
|
|
s.stream.release()
|
|
s.held.release()
|
|
await settle()
|
|
await clock.advance(1500)
|
|
await settle()
|
|
expect(s.submitted).toEqual(['first'])
|
|
})
|
|
|
|
test('a line arriving mid-burst joins it: the burst ends after quiet, not after its first line', async ($, on) => {
|
|
const clock = mock.clock(on)
|
|
const s = stubs(on, { armed: true, chunks: [{ stream: 'stdout', text: 'first\n' }] })
|
|
await $.session.start({ cwd: '/work/demo' })
|
|
await settle()
|
|
await clock.advance(1000)
|
|
s.stream.release() // 'late line', 1s into the burst
|
|
await settle()
|
|
await clock.advance(1000) // 2s after the first line, 1s after the last
|
|
await settle()
|
|
expect(s.submitted).toEqual([])
|
|
await clock.advance(500)
|
|
await settle()
|
|
expect(s.submitted).toEqual(['first\nlate line'])
|
|
})
|