pendingyou 0.9.0 → 0.10.0
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.
- package/CHANGELOG.md +49 -0
- package/README.md +80 -22
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +2 -1
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +3 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The wake mod as Claude Code runs it: `claude plugin test` in a built plugin (or the command line's mod/) loads
|
|
2
|
+
// hooks/register.js and fires real events at it, with Claude Code's answers stubbed. The rules themselves are tested
|
|
3
|
+
// in the repo (packages/claude-plugin/test/wake.test.ts); this checks the wiring. Synthetic data only.
|
|
4
|
+
import { expect, mock, test } from 'claude-code/testing'
|
|
5
|
+
|
|
6
|
+
const SERVER = 'plugin_pending-you_pendingyou'
|
|
7
|
+
const REQ = 'req_00000000000000000001'
|
|
8
|
+
const NAME = 'billing-webhooks'
|
|
9
|
+
const TITLE = 'How should failed Stripe webhooks be retried?'
|
|
10
|
+
const START = Date.parse('2026-10-04T12:00:00.000Z')
|
|
11
|
+
const REFUSED = `Claude requested permissions to use mcp__${SERVER}__get_request, but you haven't granted it yet.`
|
|
12
|
+
|
|
13
|
+
type Card = { status: string; turn: string; version: number }
|
|
14
|
+
|
|
15
|
+
/** Claude Code's answers: the session, its store and environment, the status line, prompts, and Pending You. */
|
|
16
|
+
function world(
|
|
17
|
+
on: Parameters<Parameters<typeof test>[1]>[1],
|
|
18
|
+
options: { refuse?: boolean; lease?: string } = {},
|
|
19
|
+
) {
|
|
20
|
+
const clock = mock.clock(on, { now: START })
|
|
21
|
+
const store = new Map<string, unknown>()
|
|
22
|
+
const env = new Map<string, string>(
|
|
23
|
+
options.lease ? [['PENDINGYOU_WAKE_OWNER', options.lease]] : [],
|
|
24
|
+
)
|
|
25
|
+
const card: Card = { status: 'pending', turn: 'you', version: 1 }
|
|
26
|
+
const seen = {
|
|
27
|
+
checks: [] as Record<string, unknown>[],
|
|
28
|
+
prompts: [] as string[],
|
|
29
|
+
status: [] as (string | undefined)[],
|
|
30
|
+
holds: 0,
|
|
31
|
+
}
|
|
32
|
+
on('session.start', () => ({ cwd: '/work' }))
|
|
33
|
+
on('session.id', () => ({ value: 'ses-1' }))
|
|
34
|
+
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
|
35
|
+
on('store.set', (_, e) => {
|
|
36
|
+
store.set(e.key, e.value)
|
|
37
|
+
return { value: undefined }
|
|
38
|
+
})
|
|
39
|
+
on('store.keys', () => ({ value: [...store.keys()] }))
|
|
40
|
+
on('store.delete', (_, e) => {
|
|
41
|
+
store.delete(e.key)
|
|
42
|
+
return { value: undefined }
|
|
43
|
+
})
|
|
44
|
+
on('env.get', (_, e) => ({ value: env.get(e.name) }))
|
|
45
|
+
on('env.set', (_, e) => {
|
|
46
|
+
if (e.value === undefined) env.delete(e.name)
|
|
47
|
+
else env.set(e.name, e.value)
|
|
48
|
+
return { value: undefined }
|
|
49
|
+
})
|
|
50
|
+
on('ui.status', (_, e) => {
|
|
51
|
+
seen.status.push(e.text)
|
|
52
|
+
return { value: undefined }
|
|
53
|
+
})
|
|
54
|
+
on('prompt.submit', (_, e) => {
|
|
55
|
+
seen.prompts.push(e.text)
|
|
56
|
+
return { text: e.text }
|
|
57
|
+
})
|
|
58
|
+
// Claude Code's permission rules: the whole server allowed, or nothing (the mod then never makes the call).
|
|
59
|
+
on('tool.check', () =>
|
|
60
|
+
options.refuse
|
|
61
|
+
? { decision: 'ask', reason: REFUSED }
|
|
62
|
+
: { decision: 'allow', rule: `mcp__${SERVER}` },
|
|
63
|
+
)
|
|
64
|
+
on('mcp.call', (_, e) => {
|
|
65
|
+
if (options.refuse) throw new Error('the mod called a tool Claude Code had not allowed')
|
|
66
|
+
seen.checks.push({ server: e.server, tool: e.tool, ...e.args })
|
|
67
|
+
const body = { requestId: REQ, ...card, messages: [], cursor: '', pollAfterSeconds: 30 }
|
|
68
|
+
return { value: { content: [{ type: 'text', text: JSON.stringify(body) }], isError: false } }
|
|
69
|
+
})
|
|
70
|
+
// What the tools answer when the mod lets a call through: Pending You's JSON, or a real hold's output.
|
|
71
|
+
on('tool.call', (_, e) => {
|
|
72
|
+
if (e.tool === 'Bash') {
|
|
73
|
+
seen.holds++
|
|
74
|
+
return { result: { stdout: 'the real hold ran', stderr: '', interrupted: false } }
|
|
75
|
+
}
|
|
76
|
+
const tool = String(e.tool).split('__').at(-1)
|
|
77
|
+
const body =
|
|
78
|
+
tool === 'post_request'
|
|
79
|
+
? {
|
|
80
|
+
requestId: REQ,
|
|
81
|
+
status: 'pending',
|
|
82
|
+
version: 1,
|
|
83
|
+
pollAfterSeconds: 30,
|
|
84
|
+
url: 'https://example.test',
|
|
85
|
+
}
|
|
86
|
+
: tool === 'ack_answer'
|
|
87
|
+
? { requestId: REQ, status: 'resolved', version: card.version + 1 }
|
|
88
|
+
: { requestId: REQ, ...card, messages: [], cursor: '', pollAfterSeconds: 30 }
|
|
89
|
+
return { result: JSON.stringify(body), text: JSON.stringify(body) }
|
|
90
|
+
})
|
|
91
|
+
return { clock, store, env, card, seen }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const post = {
|
|
95
|
+
tool: `mcp__${SERVER}__post_request`,
|
|
96
|
+
idempotencyKey: 'stripe-retries-1',
|
|
97
|
+
areaId: 'prj_billing',
|
|
98
|
+
session: { label: NAME },
|
|
99
|
+
kind: 'choice',
|
|
100
|
+
title: TITLE,
|
|
101
|
+
summary: 'Stripe retries only when we return an error.',
|
|
102
|
+
blocking: false,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('learns a posted card, shows it waiting, and wakes the idle session once when it’s answered', async ($, on) => {
|
|
106
|
+
const { clock, card, seen, store } = world(on)
|
|
107
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
|
108
|
+
await $.tool.call(post)
|
|
109
|
+
// Checked at once through the session's own connection, with the agent's name.
|
|
110
|
+
expect(seen.checks[0]).toEqual({
|
|
111
|
+
server: SERVER,
|
|
112
|
+
tool: 'get_request',
|
|
113
|
+
requestId: REQ,
|
|
114
|
+
name: NAME,
|
|
115
|
+
})
|
|
116
|
+
expect(seen.status.at(-1)).toBe('1 waiting on you')
|
|
117
|
+
expect(JSON.stringify(store.get('session:ses-1'))).toContain(REQ)
|
|
118
|
+
|
|
119
|
+
await clock.advance(25_000)
|
|
120
|
+
expect(seen.prompts).toEqual([])
|
|
121
|
+
card.status = 'answered'
|
|
122
|
+
card.turn = 'agent'
|
|
123
|
+
card.version = 2
|
|
124
|
+
await clock.advance(25_000)
|
|
125
|
+
expect(seen.prompts).toHaveLength(1)
|
|
126
|
+
expect(seen.prompts[0]).toContain(`your person answered “${TITLE}” (${REQ})`)
|
|
127
|
+
expect(seen.prompts[0]).toContain(`get_request (requestId ${REQ}, name “${NAME}”)`)
|
|
128
|
+
expect(seen.prompts[0]).toContain('ack_answer')
|
|
129
|
+
expect(seen.status.at(-1)).toBeUndefined()
|
|
130
|
+
|
|
131
|
+
// Once per change: nothing more until the card moves again.
|
|
132
|
+
await clock.advance(120_000)
|
|
133
|
+
expect(seen.prompts).toHaveLength(1)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('answers npx pendingyou hold itself, so nothing waits in the background', async ($, on) => {
|
|
137
|
+
const { seen } = world(on)
|
|
138
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
|
139
|
+
await $.tool.call(post)
|
|
140
|
+
const hold = await $.tool.call({
|
|
141
|
+
tool: 'Bash',
|
|
142
|
+
command: `npx pendingyou hold ${REQ}`,
|
|
143
|
+
run_in_background: true,
|
|
144
|
+
})
|
|
145
|
+
expect(seen.holds).toBe(0)
|
|
146
|
+
expect(JSON.stringify(hold)).toContain('no hold is needed in this session')
|
|
147
|
+
// Anything else runs as it is.
|
|
148
|
+
await $.tool.call({ tool: 'Bash', command: `npx pendingyou hold ${REQ} && echo done` })
|
|
149
|
+
expect(seen.holds).toBe(1)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('stops tracking a card once the agent acks it, and keeps nothing for the session', async ($, on) => {
|
|
153
|
+
const { clock, card, seen, store } = world(on)
|
|
154
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
|
155
|
+
await $.tool.call(post)
|
|
156
|
+
expect(store.has('session:ses-1')).toBe(true)
|
|
157
|
+
await $.tool.call({
|
|
158
|
+
tool: `mcp__${SERVER}__ack_answer`,
|
|
159
|
+
requestId: REQ,
|
|
160
|
+
name: NAME,
|
|
161
|
+
expectedVersion: 2,
|
|
162
|
+
})
|
|
163
|
+
expect(store.has('session:ses-1')).toBe(false)
|
|
164
|
+
card.status = 'answered'
|
|
165
|
+
card.turn = 'agent'
|
|
166
|
+
const checks = seen.checks.length
|
|
167
|
+
await clock.advance(120_000)
|
|
168
|
+
expect(seen.checks.length).toBe(checks)
|
|
169
|
+
expect(seen.prompts).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('says how to let it check when Claude Code refuses, and lets the hold run', async ($, on) => {
|
|
173
|
+
const { seen } = world(on, { refuse: true })
|
|
174
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
|
175
|
+
const posted = await $.tool.call(post)
|
|
176
|
+
expect(JSON.stringify(posted)).toContain(`allow mcp__${SERVER} in /permissions`)
|
|
177
|
+
expect(seen.status.at(-1)).toBe(`to hear answers here, allow mcp__${SERVER} in /permissions`)
|
|
178
|
+
await $.tool.call({
|
|
179
|
+
tool: 'Bash',
|
|
180
|
+
command: `npx pendingyou hold ${REQ}`,
|
|
181
|
+
run_in_background: true,
|
|
182
|
+
})
|
|
183
|
+
expect(seen.holds).toBe(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('leaves the session to the copy that holds it', async ($, on) => {
|
|
187
|
+
const { clock, card, seen } = world(on, { lease: `pendingyou-other ${START + 3_600_000}` })
|
|
188
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
|
189
|
+
await $.tool.call(post)
|
|
190
|
+
card.status = 'answered'
|
|
191
|
+
card.turn = 'agent'
|
|
192
|
+
await clock.advance(60_000)
|
|
193
|
+
expect(seen.checks).toEqual([])
|
|
194
|
+
expect(seen.prompts).toEqual([])
|
|
195
|
+
// Its hold is the other copy's to answer.
|
|
196
|
+
await $.tool.call({ tool: 'Bash', command: `npx pendingyou hold ${REQ}` })
|
|
197
|
+
expect(seen.holds).toBe(1)
|
|
198
|
+
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pending You for Claude Code: hear your answers from Pending You
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Pending You for Claude Code: hear your answers from Pending You, even while Claude Code sits idle, and set Claude Code up in one command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://www.pendingyou.com",
|
|
7
7
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
|
+
"mod",
|
|
13
14
|
"README.md",
|
|
14
15
|
"CHANGELOG.md",
|
|
15
16
|
"LICENSE"
|