pendingyou 0.2.0 → 0.3.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 +28 -0
- package/README.md +29 -3
- package/dist/args.js +43 -6
- package/dist/claude.js +40 -21
- package/dist/cli.js +13 -4
- package/dist/main.js +21 -3
- package/dist/pickup.js +17 -3
- package/dist/stopcheck.js +178 -0
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- New Stop hook, `pendingyou stopcheck`: when Claude Code ends a turn with a question for you, a step only you can do
|
|
6
|
+
or a "Your part:" list, and posted nothing to Pending You in that turn, it's asked once to post them as cards (or
|
|
7
|
+
say they don't need one). Pattern matching only, on the last turn, at most 4 MB of the transcript; quiet when not
|
|
8
|
+
signed in, never twice in a row (`stop_hook_active`), and silent on any error.
|
|
9
|
+
- The next-message hook adds one line while you're signed in: “Pending You: anything you need from your person also
|
|
10
|
+
goes on a card.”
|
|
11
|
+
- `init` adds the Stop hook (run it again on 0.2.x to get it), `uninstall` removes it, and `status` lists it.
|
|
12
|
+
|
|
13
|
+
## 0.2.1
|
|
14
|
+
|
|
15
|
+
- Fixed: after `init --origin <url>` (any origin but the default), every Claude Code message was blocked with
|
|
16
|
+
`UserPromptSubmit operation blocked by hook: … There's no "--origin" command.` 0.2.0 wrote its hooks with `--origin`
|
|
17
|
+
before the command, which it couldn't read. To repair: `npx pendingyou@latest init --origin <url>` (or, if you ran
|
|
18
|
+
`uninstall` to get unblocked, the same command sets it up again).
|
|
19
|
+
- Hooks now name the command first (`pendingyou@0.2.1 handoff --origin <url>`), add `--origin` only when it isn't the
|
|
20
|
+
default, and end in `|| true`.
|
|
21
|
+
- Options can come before or after the command.
|
|
22
|
+
- `pickup` and `handoff` never block: on any error (bad options, no network, not signed in) they exit 0, print nothing
|
|
23
|
+
for Claude Code, and leave at most a short note on stderr.
|
|
24
|
+
- Running `init` again repairs hook lines an older version wrote.
|
|
25
|
+
|
|
26
|
+
## 0.2.0
|
|
27
|
+
|
|
28
|
+
- `login --device` for computers with no browser, and `watch` for always-on scripts.
|
package/README.md
CHANGED
|
@@ -12,13 +12,37 @@ Run it once on each computer. It:
|
|
|
12
12
|
- adds the Pending You MCP server to Claude Code for every project (unless it's already there),
|
|
13
13
|
- saves the Pending You skill to `~/.claude/skills/pendingyou/SKILL.md`,
|
|
14
14
|
- signs this computer in (your browser opens once; press Allow),
|
|
15
|
-
- adds
|
|
16
|
-
|
|
15
|
+
- adds three hooks to `~/.claude/settings.json`: a new session picks up answers that arrived while nothing was
|
|
16
|
+
running, your next message brings a waiting answer with it, and a check when Claude Code finishes a turn (below),
|
|
17
17
|
- lets Claude Code start `npx pendingyou hold` and use Pending You's tools without a permission prompt, so a question
|
|
18
18
|
it asks while you're away doesn't wait on one.
|
|
19
19
|
|
|
20
20
|
Your other settings are kept as they are. `npx pendingyou uninstall` removes exactly what `init` added.
|
|
21
21
|
|
|
22
|
+
The hooks never get in your way: if anything goes wrong (no network, not signed in, a bad option), they print nothing
|
|
23
|
+
and let your message through. Running `init` again updates them in place, and adds any a newer version brings.
|
|
24
|
+
|
|
25
|
+
## Nothing left only in chat
|
|
26
|
+
|
|
27
|
+
Deep into a long conversation, Claude Code tends to end a turn with a question for you, a step only you can do, or a
|
|
28
|
+
"Your part:" list, and not put it in Pending You. Two small things catch that while you're signed in:
|
|
29
|
+
|
|
30
|
+
- Each message you send carries one line for Claude Code (about 18 tokens): *Pending You: anything you need from your
|
|
31
|
+
person also goes on a card.*
|
|
32
|
+
- When Claude Code finishes a turn, `pendingyou stopcheck` looks at its last message. If it has something for you and
|
|
33
|
+
nothing was posted, updated or replied to in Pending You that turn (and the message doesn't say it's on a card),
|
|
34
|
+
Claude Code is asked to go on once (about 31 tokens): post them as cards, or say they don't need one. It never asks
|
|
35
|
+
twice in a row.
|
|
36
|
+
|
|
37
|
+
The check is pattern matching on your computer: no model, no network. It reads only the last turn from the end of the
|
|
38
|
+
transcript (at most 4 MB) and skips code blocks and quotes. It counts as for you: a question to you (a sentence ending
|
|
39
|
+
in "?" with "you" or "your"); "you'll need to", "your step", "can you", "please run", "in your terminal", "only you
|
|
40
|
+
can", "waiting on you" and the like; or a list under a heading like "Your part", "Your steps", "For you", "Waiting on
|
|
41
|
+
you" or "Left for you".
|
|
42
|
+
|
|
43
|
+
**On 0.2.0 with another origin?** Its hooks blocked every message (`There's no "--origin" command`). Run
|
|
44
|
+
`npx pendingyou@latest init --origin <url>` again to repair them; see [CHANGELOG.md](CHANGELOG.md).
|
|
45
|
+
|
|
22
46
|
## How Claude Code uses it
|
|
23
47
|
|
|
24
48
|
After it posts a question to Pending You, Claude Code runs this in the background and keeps working:
|
|
@@ -69,9 +93,11 @@ more, then skipped. `--once` stops after the first.
|
|
|
69
93
|
| `login` / `logout` | Sign this computer in or out (`--device`, `--force`, `--all`) |
|
|
70
94
|
| `watch -- <command>` | Run a command each time an answer is ready (`--once`) |
|
|
71
95
|
| `pickup` / `handoff` | The session-start and next-message hooks |
|
|
96
|
+
| `stopcheck` | The Stop hook: items for you left only in chat |
|
|
72
97
|
| `uninstall` | Remove what `init` added, and sign out |
|
|
73
98
|
|
|
74
|
-
Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`).
|
|
99
|
+
Every command takes `--origin` (default `https://www.pendingyou.com`, or `$PENDINGYOU_ORIGIN`). Options can come
|
|
100
|
+
before or after the command.
|
|
75
101
|
|
|
76
102
|
## What it can see, and where it keeps things
|
|
77
103
|
|
package/dist/args.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The command line's arguments: one command
|
|
2
|
-
//
|
|
1
|
+
// The command line's arguments: one command and its flags, which may come before or after it (`pendingyou handoff
|
|
2
|
+
// --origin <url>` and `pendingyou --origin <url> handoff` are the same; 0.2.0 wrote its hooks the second way). No
|
|
3
|
+
// dependencies, no surprises: an unknown flag or a stray argument is an error, never ignored.
|
|
3
4
|
export const DEFAULT_ORIGIN = 'https://www.pendingyou.com';
|
|
4
5
|
/** How long `hold` waits for an answer before it lets go (4 hours). */
|
|
5
6
|
export const DEFAULT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
@@ -44,6 +45,7 @@ const COMMANDS = new Set([
|
|
|
44
45
|
'hold',
|
|
45
46
|
'pickup',
|
|
46
47
|
'handoff',
|
|
48
|
+
'stopcheck',
|
|
47
49
|
'init',
|
|
48
50
|
'uninstall',
|
|
49
51
|
'watch',
|
|
@@ -58,18 +60,48 @@ const FLAGS = {
|
|
|
58
60
|
hold: { origin: true, timeout: true },
|
|
59
61
|
pickup: { origin: true },
|
|
60
62
|
handoff: { origin: true },
|
|
63
|
+
stopcheck: { origin: true },
|
|
61
64
|
init: { origin: true, 'no-login': false, 'no-browser': false, device: false },
|
|
62
65
|
uninstall: { origin: true },
|
|
63
66
|
watch: { origin: true, once: false },
|
|
64
67
|
};
|
|
68
|
+
/** Options that take a value, whichever command they belong to: needed to find the command behind them. */
|
|
69
|
+
const VALUE_OPTIONS = new Set(['origin', 'timeout']);
|
|
70
|
+
/** Splits off the options given before the command: [those options, the command, everything after it]. */
|
|
71
|
+
function splitCommand(argv) {
|
|
72
|
+
let index = 0;
|
|
73
|
+
while (index < argv.length) {
|
|
74
|
+
const arg = argv[index];
|
|
75
|
+
if (arg === '--' || !arg.startsWith('-'))
|
|
76
|
+
break;
|
|
77
|
+
index += arg.startsWith('--') && !arg.includes('=') && VALUE_OPTIONS.has(arg.slice(2)) ? 2 : 1;
|
|
78
|
+
}
|
|
79
|
+
return [argv.slice(0, index), argv[index], argv.slice(index + 1)];
|
|
80
|
+
}
|
|
81
|
+
/** The command the arguments name, wherever its options are; undefined when there isn't one. */
|
|
82
|
+
export function commandOf(argv) {
|
|
83
|
+
return splitCommand(argv)[1];
|
|
84
|
+
}
|
|
85
|
+
/** The commands Claude Code's hooks run. Whatever goes wrong, they exit 0 and never block the person. */
|
|
86
|
+
export const HOOK_COMMANDS = new Set(['pickup', 'handoff', 'stopcheck']);
|
|
65
87
|
export function parseArgs(argv, env = {}) {
|
|
66
|
-
const [first,
|
|
67
|
-
|
|
88
|
+
const [leading, first, after] = splitCommand(argv);
|
|
89
|
+
const helps = (arg) => arg === '-h' || arg === '--help';
|
|
90
|
+
const versions = (arg) => arg === '-v' || arg === '--version';
|
|
91
|
+
if (leading.some(helps))
|
|
68
92
|
return { name: 'help' };
|
|
69
|
-
if (first ===
|
|
93
|
+
if (first === undefined) {
|
|
94
|
+
if (leading.some(versions))
|
|
95
|
+
return { name: 'version' };
|
|
96
|
+
if (leading.length === 0)
|
|
97
|
+
return { name: 'help' };
|
|
98
|
+
throw new UsageError('Which command? Like: pendingyou status');
|
|
99
|
+
}
|
|
100
|
+
if (leading.some(versions))
|
|
70
101
|
return { name: 'version' };
|
|
71
102
|
if (!COMMANDS.has(first))
|
|
72
103
|
throw new UsageError(`There’s no “${first}” command.`);
|
|
104
|
+
const rest = [...leading, ...after];
|
|
73
105
|
const allowed = FLAGS[first] ?? {};
|
|
74
106
|
const flags = new Map();
|
|
75
107
|
const positional = [];
|
|
@@ -139,6 +171,9 @@ export function parseArgs(argv, env = {}) {
|
|
|
139
171
|
case 'handoff':
|
|
140
172
|
none();
|
|
141
173
|
return { name: 'handoff', origin };
|
|
174
|
+
case 'stopcheck':
|
|
175
|
+
none();
|
|
176
|
+
return { name: 'stopcheck', origin };
|
|
142
177
|
case 'uninstall':
|
|
143
178
|
none();
|
|
144
179
|
return { name: 'uninstall', origin };
|
|
@@ -174,7 +209,8 @@ export function parseArgs(argv, env = {}) {
|
|
|
174
209
|
export const USAGE = `pendingyou: Pending You for Claude Code
|
|
175
210
|
|
|
176
211
|
npx pendingyou init Set up Claude Code on this computer: the MCP server, the skill,
|
|
177
|
-
this command's sign-in, and
|
|
212
|
+
this command's sign-in, and three hooks (answers, and a check
|
|
213
|
+
that what Claude Code leaves you in chat is also in Pending You)
|
|
178
214
|
npx pendingyou hold <req_…> Wait in the background until the answer to a request is ready,
|
|
179
215
|
print it, and exit (which wakes Claude Code)
|
|
180
216
|
npx pendingyou status What's set up, and whether Claude Code hears answers right away
|
|
@@ -185,6 +221,7 @@ export const USAGE = `pendingyou: Pending You for Claude Code
|
|
|
185
221
|
npx pendingyou uninstall Remove exactly what init added
|
|
186
222
|
npx pendingyou pickup Answers waiting for this folder (the session-start hook)
|
|
187
223
|
npx pendingyou handoff The same, only while a hold is outstanding (the next-message hook)
|
|
224
|
+
npx pendingyou stopcheck Questions or steps for you left only in chat (the Stop hook)
|
|
188
225
|
|
|
189
226
|
Options: --origin <url> (default ${DEFAULT_ORIGIN}), --timeout 4h (hold), --no-browser and --device (login,
|
|
190
227
|
init), --no-login (init), --all (logout), --force (login), --once (watch).
|
package/dist/claude.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Claude Code's side of `pendingyou init` and `uninstall`: the Pending You MCP server (user scope), the stub skill, the
|
|
2
|
-
//
|
|
2
|
+
// three hooks (session-start pickup, next-message hand-off, the Stop check) and the permissions the background wait needs, in
|
|
3
3
|
// ~/.claude/settings.json. Settings are merged, never replaced: a file that isn't JSON is left alone, entries that
|
|
4
4
|
// aren't ours are never touched, and running init again changes nothing it doesn't need to. What init added is written
|
|
5
5
|
// down in ~/.config/pendingyou/claude-code.json, so uninstall removes exactly that.
|
|
@@ -12,7 +12,11 @@ import { claudeDir, configDir, readJson, readText, writeWhole } from "./files.js
|
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
13
|
export const MCP_NAME = 'pendingyou';
|
|
14
14
|
/** The hooks, by Claude Code event, and the command each runs. */
|
|
15
|
-
export const HOOKS = {
|
|
15
|
+
export const HOOKS = {
|
|
16
|
+
SessionStart: 'pickup',
|
|
17
|
+
UserPromptSubmit: 'handoff',
|
|
18
|
+
Stop: 'stopcheck',
|
|
19
|
+
};
|
|
16
20
|
/** Seconds Claude Code gives each hook; they time-box themselves well inside it. */
|
|
17
21
|
const HOOK_TIMEOUT = 10;
|
|
18
22
|
/**
|
|
@@ -31,18 +35,26 @@ const quote = (text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceA
|
|
|
31
35
|
* How a hook runs this command line: through npx when that's how it was started (pinned to this version, from npx's
|
|
32
36
|
* cache), the installed `pendingyou` when it's installed, or this very file.
|
|
33
37
|
*/
|
|
34
|
-
export function selfCommand(io
|
|
38
|
+
export function selfCommand(io) {
|
|
35
39
|
const script = io.script.replaceAll('\\', '/');
|
|
36
|
-
|
|
40
|
+
return io.env.PENDINGYOU_SELF
|
|
37
41
|
? io.env.PENDINGYOU_SELF
|
|
38
42
|
: !script || script.includes('/_npx/')
|
|
39
43
|
? `npx -y --prefer-offline pendingyou@${VERSION}`
|
|
40
44
|
: script.includes('/node_modules/pendingyou/')
|
|
41
45
|
? 'pendingyou'
|
|
42
46
|
: `node ${quote(io.script)}`;
|
|
43
|
-
return origin === DEFAULT_ORIGIN ? base : `${base} --origin ${quote(origin)}`;
|
|
44
47
|
}
|
|
45
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* A hook's command line: the command first, then `--origin` only when it isn't the default. `|| true` keeps the hook
|
|
50
|
+
* from ever blocking the person, even when npx can't fetch the package or the command fails before it starts (Claude
|
|
51
|
+
* Code blocks a message when its UserPromptSubmit hook exits 2, and flags any other non-zero exit).
|
|
52
|
+
*/
|
|
53
|
+
export function hookCommand(io, origin, sub) {
|
|
54
|
+
const flag = origin === DEFAULT_ORIGIN ? '' : ` --origin ${quote(origin)}`;
|
|
55
|
+
return `${selfCommand(io)} ${sub}${flag} || true`;
|
|
56
|
+
}
|
|
57
|
+
/** Whether a hook command is one of ours: it runs pendingyou's `pickup`, `handoff` or `stopcheck`. */
|
|
46
58
|
export function isOurHook(command, sub) {
|
|
47
59
|
if (typeof command !== 'string' || !command.includes('pendingyou'))
|
|
48
60
|
return false;
|
|
@@ -71,7 +83,10 @@ const objectAt = (parent, key) => {
|
|
|
71
83
|
? value
|
|
72
84
|
: null;
|
|
73
85
|
};
|
|
74
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Adds our hooks and permissions, or updates our hooks in place (a new version, or a line an older version wrote, like
|
|
88
|
+
* 0.2.0's `… --origin <url> handoff`, which 0.2.0 itself can't read). Returns the permissions it added.
|
|
89
|
+
*/
|
|
75
90
|
export function mergeSettings(settings, commands) {
|
|
76
91
|
const next = structuredClone(settings);
|
|
77
92
|
let changed = false;
|
|
@@ -80,7 +95,7 @@ export function mergeSettings(settings, commands) {
|
|
|
80
95
|
throw new PlainError('Claude Code’s settings have a “hooks” that isn’t an object, so I left them alone.');
|
|
81
96
|
for (const event of Object.keys(HOOKS)) {
|
|
82
97
|
const sub = HOOKS[event];
|
|
83
|
-
const wanted =
|
|
98
|
+
const wanted = commands[event];
|
|
84
99
|
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
85
100
|
if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
|
|
86
101
|
throw new PlainError(`Claude Code’s ${event} hooks aren’t a list, so I left them alone.`);
|
|
@@ -231,15 +246,17 @@ export async function installClaude(io, origin) {
|
|
|
231
246
|
steps.push(mcp.step);
|
|
232
247
|
const skill = await saveSkill(io, origin, before?.skill ?? null);
|
|
233
248
|
steps.push(skill.step);
|
|
234
|
-
const
|
|
235
|
-
|
|
249
|
+
const commands = Object.fromEntries(Object.keys(HOOKS).map((event) => [
|
|
250
|
+
event,
|
|
251
|
+
hookCommand(io, origin, HOOKS[event]),
|
|
252
|
+
]));
|
|
236
253
|
const merged = mergeSettings(await readSettings(io), commands);
|
|
237
254
|
if (merged.changed)
|
|
238
255
|
await writeSettings(io, merged.settings);
|
|
239
256
|
steps.push({
|
|
240
257
|
ok: true,
|
|
241
258
|
text: merged.changed
|
|
242
|
-
? `Installed the session-start pickup
|
|
259
|
+
? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
|
|
243
260
|
: 'The hooks were already installed.',
|
|
244
261
|
});
|
|
245
262
|
const manifest = {
|
|
@@ -248,10 +265,7 @@ export async function installClaude(io, origin) {
|
|
|
248
265
|
claudeDir: claudeDir(io),
|
|
249
266
|
mcpAdded: mcp.added || (before?.mcpAdded ?? false),
|
|
250
267
|
skill: skill.skill,
|
|
251
|
-
hooks:
|
|
252
|
-
SessionStart: `${self} ${HOOKS.SessionStart}`,
|
|
253
|
-
UserPromptSubmit: `${self} ${HOOKS.UserPromptSubmit}`,
|
|
254
|
-
},
|
|
268
|
+
hooks: commands,
|
|
255
269
|
permissions: [...new Set([...(before?.permissions ?? []), ...merged.permissionsAdded])],
|
|
256
270
|
};
|
|
257
271
|
await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, { secret: true });
|
|
@@ -299,13 +313,18 @@ export async function claudeState(io) {
|
|
|
299
313
|
const claude = version.code === 0 ? version.stdout.trim().split(/\s+/)[0] || 'installed' : null;
|
|
300
314
|
const mcp = claude ? (await io.run('claude', ['mcp', 'get', MCP_NAME], 20_000)).code === 0 : false;
|
|
301
315
|
const skill = (await readText(skillPath(io))) !== null;
|
|
302
|
-
let
|
|
316
|
+
let installed = (_event) => false;
|
|
303
317
|
try {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
all[event].some((group) => (group.hooks ?? []).some((hook) => isOurHook(hook.command, HOOKS[event]))));
|
|
318
|
+
const all = objectAt(await readSettings(io), 'hooks') ?? {};
|
|
319
|
+
installed = (event) => Array.isArray(all[event]) &&
|
|
320
|
+
all[event].some((group) => (group.hooks ?? []).some((hook) => isOurHook(hook.command, HOOKS[event])));
|
|
308
321
|
}
|
|
309
322
|
catch { }
|
|
310
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
claude,
|
|
325
|
+
mcp,
|
|
326
|
+
skill,
|
|
327
|
+
hooks: installed('SessionStart') && installed('UserPromptSubmit'),
|
|
328
|
+
stopcheck: installed('Stop'),
|
|
329
|
+
};
|
|
311
330
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `npx pendingyou …`: runs the command line with the real computer (realIo) and exits with its code.
|
|
3
3
|
import { realIo } from "./io.js";
|
|
4
|
-
import { main } from "./main.js";
|
|
5
|
-
|
|
4
|
+
import { isHook, main } from "./main.js";
|
|
5
|
+
const argv = process.argv.slice(2);
|
|
6
|
+
// A hook (pickup, handoff, stopcheck) exits 0 even here: it must never block the person's message.
|
|
7
|
+
const failed = isHook(argv) ? 0 : 1;
|
|
8
|
+
try {
|
|
9
|
+
main(argv, realIo()).then((code) => process.exit(code), () => {
|
|
10
|
+
process.stderr.write('pendingyou: Something went wrong.\n');
|
|
11
|
+
process.exit(failed);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
6
15
|
process.stderr.write('pendingyou: Something went wrong.\n');
|
|
7
|
-
process.exit(
|
|
8
|
-
}
|
|
16
|
+
process.exit(failed);
|
|
17
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The command line: parse the arguments, run the command, return the exit code. `cli.ts` runs it for real; tests run
|
|
2
2
|
// it with their own Io.
|
|
3
3
|
import { getJson, SignInNeeded, Unavailable } from "./api.js";
|
|
4
|
-
import { parseArgs, USAGE, UsageError } from "./args.js";
|
|
4
|
+
import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
|
|
5
5
|
import { claudeState, installClaude, uninstallClaude } from "./claude.js";
|
|
6
6
|
import { readCredential, signedInOrigins, updateCredential } from "./credentials.js";
|
|
7
7
|
import { PlainError } from "./errors.js";
|
|
8
8
|
import { hold } from "./hold.js";
|
|
9
9
|
import { OAuthError, revoke, signIn, signInWithDevice } from "./oauth.js";
|
|
10
10
|
import { pickup } from "./pickup.js";
|
|
11
|
+
import { stopcheck } from "./stopcheck.js";
|
|
11
12
|
import { VERSION } from "./version.js";
|
|
12
13
|
import { watch } from "./watch.js";
|
|
13
14
|
/** Whether this computer's sign-in works, and how many Claude Code connections it hears for. */
|
|
@@ -81,7 +82,7 @@ async function status(io, origin) {
|
|
|
81
82
|
? 'ended; run npx pendingyou login'
|
|
82
83
|
: signIn.state === 'none'
|
|
83
84
|
? 'not signed in; run npx pendingyou login'
|
|
84
|
-
: 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(claude.mcp)} MCP server: ${claude.mcp ? 'pendingyou' : 'not added; run npx pendingyou init'}`, ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : 'not saved; run npx pendingyou init'}`, ` ${mark(claude.hooks)} Hooks: ${claude.hooks ? 'session-start pickup and next-message hand-off' : 'not installed; run npx pendingyou init'}`);
|
|
85
|
+
: 'Pending You couldn’t be reached'}`, ` ${mark(claude.claude !== null)} Claude Code: ${claude.claude ?? 'not found on PATH'}`, ` ${mark(claude.mcp)} MCP server: ${claude.mcp ? 'pendingyou' : 'not added; run npx pendingyou init'}`, ` ${mark(claude.skill)} Skill: ${claude.skill ? 'saved' : 'not saved; run npx pendingyou init'}`, ` ${mark(claude.hooks)} Hooks: ${claude.hooks ? 'session-start pickup and next-message hand-off' : 'not installed; run npx pendingyou init'}`, ` ${mark(claude.stopcheck)} Stop check: ${claude.stopcheck ? 'asks Claude Code to post what it leaves you in chat' : 'not installed; run npx pendingyou init'}`);
|
|
85
86
|
const ready = signIn.state === 'ok' && claude.hooks;
|
|
86
87
|
lines.push(ready
|
|
87
88
|
? 'Ready: Claude Code hears answers right away (report_setup hears "instant").'
|
|
@@ -135,6 +136,8 @@ async function run(io, command) {
|
|
|
135
136
|
return pickup(io, { origin: command.origin, mode: 'pickup' });
|
|
136
137
|
case 'handoff':
|
|
137
138
|
return pickup(io, { origin: command.origin, mode: 'handoff' });
|
|
139
|
+
case 'stopcheck':
|
|
140
|
+
return stopcheck(io, { origin: command.origin });
|
|
138
141
|
case 'init':
|
|
139
142
|
return init(io, command);
|
|
140
143
|
case 'uninstall':
|
|
@@ -143,18 +146,33 @@ async function run(io, command) {
|
|
|
143
146
|
return watch(io, command);
|
|
144
147
|
}
|
|
145
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether these arguments run a Claude Code hook (pickup, handoff or stopcheck). A hook must never get in the way of the person:
|
|
151
|
+
* Claude Code blocks their message when a UserPromptSubmit hook exits 2 and flags any other failure, so a hook exits 0
|
|
152
|
+
* whatever goes wrong, with at most a short note on stderr (which Claude Code doesn't add to the conversation).
|
|
153
|
+
*/
|
|
154
|
+
export const isHook = (argv) => HOOK_COMMANDS.has(commandOf(argv) ?? '');
|
|
146
155
|
export async function main(argv, io) {
|
|
156
|
+
const hook = isHook(argv);
|
|
147
157
|
let command;
|
|
148
158
|
try {
|
|
149
159
|
command = parseArgs(argv, io.env);
|
|
150
160
|
}
|
|
151
161
|
catch (error) {
|
|
162
|
+
if (hook) {
|
|
163
|
+
const message = error instanceof UsageError ? error.message : 'Something went wrong.';
|
|
164
|
+
io.err(`pendingyou: ${message} Skipped this hook; run npx pendingyou@latest init to repair it.\n`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
152
167
|
if (error instanceof UsageError) {
|
|
153
168
|
io.err(`${error.message}\n\n${USAGE}`);
|
|
154
169
|
return 2;
|
|
155
170
|
}
|
|
156
171
|
throw error;
|
|
157
172
|
}
|
|
173
|
+
// `pickup --help` and the like: nothing for Claude Code to add to the conversation.
|
|
174
|
+
if (hook && !HOOK_COMMANDS.has(command.name))
|
|
175
|
+
return 0;
|
|
158
176
|
try {
|
|
159
177
|
return await run(io, command);
|
|
160
178
|
}
|
|
@@ -167,6 +185,6 @@ export async function main(argv, io) {
|
|
|
167
185
|
? error.message
|
|
168
186
|
: 'Something went wrong.';
|
|
169
187
|
io.err(`pendingyou: ${message}\n`);
|
|
170
|
-
return 1;
|
|
188
|
+
return hook ? 0 : 1;
|
|
171
189
|
}
|
|
172
190
|
}
|
package/dist/pickup.js
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
// running, for questions asked from this folder.
|
|
5
5
|
// - `pendingyou handoff` (UserPromptSubmit): your next message brings a waiting answer with it ("You answered in
|
|
6
6
|
// Pending You: …"). It only calls Pending You while a hold from this computer is outstanding, so an ordinary message
|
|
7
|
-
// costs no network call.
|
|
7
|
+
// costs no network call. While signed in, it also adds one short line (REMINDER) to every message, so an assistant
|
|
8
|
+
// deep in a long conversation still puts what it needs from the person on a card.
|
|
8
9
|
//
|
|
9
10
|
// Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
|
|
10
11
|
// prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
|
|
11
12
|
import { getJson, SignInNeeded } from "./api.js";
|
|
13
|
+
import { readCredential } from "./credentials.js";
|
|
12
14
|
import { describe, isClosed } from "./format.js";
|
|
13
15
|
import { markHanded, momentOf, waitingOn, wasHanded } from "./state.js";
|
|
14
16
|
const TIME_BOX_MS = { pickup: 5000, handoff: 2500 };
|
|
17
|
+
/** What every message carries while signed in (about 18 tokens). */
|
|
18
|
+
export const REMINDER = 'Pending You: anything you need from your person also goes on a card.';
|
|
15
19
|
/** The folder Claude Code says the session is in (the hook's stdin), or where this runs. */
|
|
16
20
|
async function sessionFolder(io) {
|
|
17
21
|
const text = await io.readStdin(500).catch(() => '');
|
|
@@ -33,10 +37,14 @@ export function folderForms(cwd, home) {
|
|
|
33
37
|
}
|
|
34
38
|
export async function pickup(io, options) {
|
|
35
39
|
const { origin, mode } = options;
|
|
40
|
+
let remind = false;
|
|
36
41
|
try {
|
|
37
42
|
const cwd = await sessionFolder(io);
|
|
38
|
-
if (mode === 'handoff'
|
|
39
|
-
|
|
43
|
+
if (mode === 'handoff') {
|
|
44
|
+
remind = (await readCredential(io, origin)) !== null;
|
|
45
|
+
if ((await waitingOn(io, origin)).length === 0)
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
40
48
|
const query = new URLSearchParams([
|
|
41
49
|
...folderForms(cwd, io.home).map((form) => ['cwd', form]),
|
|
42
50
|
['limit', '5'],
|
|
@@ -69,6 +77,12 @@ export async function pickup(io, options) {
|
|
|
69
77
|
catch (error) {
|
|
70
78
|
if (mode === 'pickup' && error instanceof SignInNeeded && error.ended)
|
|
71
79
|
io.out('Pending You: this computer’s pendingyou sign-in has ended, so answers can’t wake Claude Code. Ask your person to run `npx pendingyou login`.\n');
|
|
80
|
+
if (error instanceof SignInNeeded)
|
|
81
|
+
remind = false;
|
|
72
82
|
return 0;
|
|
73
83
|
}
|
|
84
|
+
finally {
|
|
85
|
+
if (remind)
|
|
86
|
+
io.out(`${REMINDER}\n`);
|
|
87
|
+
}
|
|
74
88
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `pendingyou stopcheck`, the Stop hook `init` installs: when Claude Code is about to end its turn with something for
|
|
2
|
+
// the person in the chat (a question to them, a step only they can do, a "Your part:" list) and it posted nothing to
|
|
3
|
+
// Pending You in that turn, it asks Claude Code to go on and post it (`{"decision":"block","reason":…}`), once.
|
|
4
|
+
//
|
|
5
|
+
// Pattern matching only, no model, no network: it reads the hook's JSON, the final message, and the tail of the
|
|
6
|
+
// transcript back to the person's last message, never more than READ_CAP bytes. It never loops (stop_hook_active),
|
|
7
|
+
// stays quiet for anyone not signed in to this origin, and exits 0 silently on any error.
|
|
8
|
+
import { open } from 'node:fs/promises';
|
|
9
|
+
import { readCredential } from "./credentials.js";
|
|
10
|
+
/** The most of the transcript it reads, from the end. A turn longer than this is let through. */
|
|
11
|
+
export const READ_CAP = 4 * 1024 * 1024;
|
|
12
|
+
const CHUNK = 64 * 1024;
|
|
13
|
+
/** What Claude Code reads when it's asked to go on: one short line (about 30 tokens). */
|
|
14
|
+
export const STOP_REASON = 'Items for your person are in chat but not in Pending You: post them as cards (ask in both places), or say they don’t need one.';
|
|
15
|
+
/** Tools that put something in front of the person: Pending You's, under any MCP server name. */
|
|
16
|
+
const POSTING_TOOL = /^mcp__.+__(post_request|update_request|reply_in_thread)$/;
|
|
17
|
+
/** Text that says the items are already in Pending You. */
|
|
18
|
+
const SAYS_POSTED = /\b(posted|put|added|raised|filed)\b[^.\n]{0,60}\b(cards?|pending ?you)\b|\bon (a|the|its|their|your|that|this) cards?\b|\b(in|to|through|via|on) pending ?you\b|\bpending ?you cards?\b/i;
|
|
19
|
+
/** Steps only the person can take, said to them. */
|
|
20
|
+
const STEPS_FOR_THEM = /\b(you(?:'|’)ll need to|you will need to|you(?:'|’)ll have to|you will have to|your (?:next )?steps?\b|can you|could you|would you mind|please (?:run|confirm|approve|check|add|set|sign|create|open|paste|reply|send|choose|pick|decide|let me know)|in your terminal|only you can|waiting on you|waiting for you|needs? you to|over to you|parked for you|when you(?:'|’)re ready|up to you|your call\b|is yours\b|say the word|let me know (?:which|whether)|tell me (?:which|whether)|needs? your (?:ok|okay|approval|decision|sign-?off|go-ahead))/i;
|
|
21
|
+
/** A heading over a list of things for the person. */
|
|
22
|
+
const FOR_THEM_HEADING = /\b(your part|your steps?|your to-?dos?|your turn|your call|for you|waiting on you|left for you|parked for you|needs you|over to you|what you need to do|you(?:'|’)ll need)\b/i;
|
|
23
|
+
const LIST_ITEM = /^\s*(?:\d+[.)]|[-*•]|\[ \])\s+\S/;
|
|
24
|
+
const YOU = /\byou(?:r|rs|(?:'|’)(?:ll|d|re|ve))?\b/i;
|
|
25
|
+
/** The text without what isn't said to the person: code (fenced and inline), block quotes and quoted strings. */
|
|
26
|
+
export function plainText(text) {
|
|
27
|
+
const kept = [];
|
|
28
|
+
let fence = null;
|
|
29
|
+
for (const line of text.split('\n')) {
|
|
30
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
31
|
+
if (fence) {
|
|
32
|
+
if (marker?.startsWith(fence))
|
|
33
|
+
fence = null;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (marker) {
|
|
37
|
+
fence = marker;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (/^\s*>/.test(line))
|
|
41
|
+
continue;
|
|
42
|
+
kept.push(line.replace(/`[^`]*`/g, '').replace(/“[^”]*”|"[^"]*"/g, ''));
|
|
43
|
+
}
|
|
44
|
+
return kept.join('\n');
|
|
45
|
+
}
|
|
46
|
+
/** A heading's words (`## Your part`, `**Your part:**`, `Your part:`), or null when the line isn't one. */
|
|
47
|
+
function headingOf(line) {
|
|
48
|
+
const bare = line.replace(/[*_#]/g, '').trim();
|
|
49
|
+
if (!bare || bare.length > 80)
|
|
50
|
+
return null;
|
|
51
|
+
return /^\s*#/.test(line) || bare.endsWith(':') || /^\s*\*\*.+\*\*\s*$/.test(line) ? bare : null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What in the final text is for the person, or null: `list` (a list under a heading like "Your part:"), `steps`
|
|
55
|
+
* (something only they can do) or `question` (a question to them).
|
|
56
|
+
*/
|
|
57
|
+
export function itemsForPerson(text) {
|
|
58
|
+
const plain = plainText(text);
|
|
59
|
+
const lines = plain.split('\n');
|
|
60
|
+
for (let index = 0; index < lines.length; index++) {
|
|
61
|
+
const heading = headingOf(lines[index]);
|
|
62
|
+
if (!heading || !FOR_THEM_HEADING.test(heading))
|
|
63
|
+
continue;
|
|
64
|
+
const next = lines.slice(index + 1, index + 4).find((candidate) => candidate.trim());
|
|
65
|
+
if (next && LIST_ITEM.test(next))
|
|
66
|
+
return 'list';
|
|
67
|
+
}
|
|
68
|
+
if (STEPS_FOR_THEM.test(plain))
|
|
69
|
+
return 'steps';
|
|
70
|
+
for (const sentence of plain.split(/(?<=[.!?])\s+|\n/))
|
|
71
|
+
if (sentence.trim().endsWith('?') && YOU.test(sentence))
|
|
72
|
+
return 'question';
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Whether the text says the items are already in Pending You. */
|
|
76
|
+
export const saysPosted = (text) => SAYS_POSTED.test(plainText(text));
|
|
77
|
+
/** A message from the person (not a tool's result): where a turn starts. */
|
|
78
|
+
function startsTurn(entry) {
|
|
79
|
+
if (entry.type !== 'user' || entry.isMeta || entry.message?.role !== 'user')
|
|
80
|
+
return false;
|
|
81
|
+
const content = entry.message.content;
|
|
82
|
+
if (typeof content === 'string')
|
|
83
|
+
return true;
|
|
84
|
+
return Array.isArray(content) && content.some((block) => block?.type === 'text');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The last turn of a Claude Code transcript (JSONL): read backwards from the end in chunks, line by line, until the
|
|
88
|
+
* person's last message, never more than `cap` bytes.
|
|
89
|
+
*/
|
|
90
|
+
export async function lastTurn(path, cap = READ_CAP) {
|
|
91
|
+
const file = await open(path, 'r');
|
|
92
|
+
try {
|
|
93
|
+
const size = (await file.stat()).size;
|
|
94
|
+
let position = size;
|
|
95
|
+
let bytesRead = 0;
|
|
96
|
+
let carry = Buffer.alloc(0);
|
|
97
|
+
const texts = [];
|
|
98
|
+
let finalDone = false;
|
|
99
|
+
let posted = false;
|
|
100
|
+
const take = (raw) => {
|
|
101
|
+
if (!raw.trim())
|
|
102
|
+
return false;
|
|
103
|
+
let entry;
|
|
104
|
+
try {
|
|
105
|
+
entry = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (entry.isSidechain)
|
|
111
|
+
return false;
|
|
112
|
+
if (startsTurn(entry))
|
|
113
|
+
return true;
|
|
114
|
+
if (entry.type !== 'assistant' || !Array.isArray(entry.message?.content))
|
|
115
|
+
return false;
|
|
116
|
+
const blocks = entry.message.content;
|
|
117
|
+
for (const block of [...blocks].reverse()) {
|
|
118
|
+
if (block?.type === 'tool_use') {
|
|
119
|
+
finalDone = true;
|
|
120
|
+
const name = typeof block.name === 'string' ? block.name : '';
|
|
121
|
+
const command = typeof block.input?.command === 'string' ? block.input.command : '';
|
|
122
|
+
if (POSTING_TOOL.test(name) || /\bpendingyou\b.*\bhold\s+req_/.test(command))
|
|
123
|
+
posted = true;
|
|
124
|
+
}
|
|
125
|
+
else if (block?.type === 'text' && !finalDone && typeof block.text === 'string')
|
|
126
|
+
texts.unshift(block.text);
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
while (position > 0 && bytesRead < cap) {
|
|
131
|
+
const length = Math.min(CHUNK, position, cap - bytesRead);
|
|
132
|
+
position -= length;
|
|
133
|
+
const chunk = Buffer.alloc(length);
|
|
134
|
+
await file.read(chunk, 0, length, position);
|
|
135
|
+
bytesRead += length;
|
|
136
|
+
let buffer = Buffer.concat([chunk, carry]);
|
|
137
|
+
let newline = buffer.lastIndexOf(10);
|
|
138
|
+
while (newline !== -1) {
|
|
139
|
+
if (take(buffer.subarray(newline + 1).toString('utf8')))
|
|
140
|
+
return { text: texts.join('\n'), posted, complete: true, bytesRead };
|
|
141
|
+
buffer = buffer.subarray(0, newline);
|
|
142
|
+
newline = buffer.lastIndexOf(10);
|
|
143
|
+
}
|
|
144
|
+
carry = buffer;
|
|
145
|
+
}
|
|
146
|
+
if (position === 0 && take(carry.toString('utf8')))
|
|
147
|
+
return { text: texts.join('\n'), posted, complete: true, bytesRead };
|
|
148
|
+
return { text: texts.join('\n'), posted, complete: position === 0, bytesRead };
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await file.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export async function stopcheck(io, options) {
|
|
155
|
+
try {
|
|
156
|
+
const input = JSON.parse(await io.readStdin(1000));
|
|
157
|
+
if (typeof input !== 'object' || input === null || input.stop_hook_active === true)
|
|
158
|
+
return 0;
|
|
159
|
+
if (typeof input.transcript_path !== 'string' || !input.transcript_path)
|
|
160
|
+
return 0;
|
|
161
|
+
if (!(await readCredential(io, options.origin)))
|
|
162
|
+
return 0;
|
|
163
|
+
const turn = await lastTurn(input.transcript_path);
|
|
164
|
+
if (!turn.complete)
|
|
165
|
+
return 0;
|
|
166
|
+
// The transcript can lag the final message; Claude Code hands it over itself.
|
|
167
|
+
const text = typeof input.last_assistant_message === 'string' && input.last_assistant_message.trim()
|
|
168
|
+
? input.last_assistant_message
|
|
169
|
+
: turn.text;
|
|
170
|
+
if (turn.posted || !text || saysPosted(text) || !itemsForPerson(text))
|
|
171
|
+
return 0;
|
|
172
|
+
io.out(`${JSON.stringify({ decision: 'block', reason: STOP_REASON })}\n`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// The command line's version, as published (package.json). A test keeps them the same.
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.3.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pendingyou",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pending You for Claude Code: hear your answers from Pending You in the background, and set Claude Code up in one command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://www.pendingyou.com",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"engines": {
|