@raidiant/notifai 0.5.0 → 1.0.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 +165 -0
- package/README.md +11 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|
9
|
+
and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
10
|
+
|
|
11
|
+
## [Unreleased]
|
|
12
|
+
|
|
13
|
+
## [1.0.0] - 2026-08-13
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- **BREAKING** Service origin is no longer a user setting. The compiled default and the origin stored on the credential stay; override only with `--base-url` or `NOTIFAI_BASE_URL`.
|
|
18
|
+
- **BREAKING** Personal project preferences leave the project tree and live under the user config directory, keyed by project root.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- A bounded, consented slice of the local log can be packed for feedback.
|
|
23
|
+
- Test notifications are sent by kind rather than by inventing a sound.
|
|
24
|
+
- The agent skill walks setup itself and treats `--sound` / `--level` as overrides.
|
|
25
|
+
- Every detected harness is installed instead of picking one.
|
|
26
|
+
- The interactive app offers labeled hooks install and uninstall.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Codex hooks are written in the layer's existing representation.
|
|
31
|
+
- Readiness work that cannot change the next action is skipped.
|
|
32
|
+
- Unfinished import changes from the skill rewrite are dropped.
|
|
33
|
+
- Setup closes are short.
|
|
34
|
+
|
|
35
|
+
## [0.5.1] - 2026-08-13
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Republished the 0.5.0 source. The previous tarball carried 0.4.0's compiled output, so agent-wake modules never reached npm.
|
|
40
|
+
|
|
41
|
+
## [0.5.0] - 2026-08-13
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Answers now reach the agent that asked without the user returning to the terminal.
|
|
46
|
+
- Asynchronous Claude Code Stop hook: it returns at once and posts the answer into the same session over its inbox socket.
|
|
47
|
+
- Codex Stop hook continues the turn directly, and resumes a stopped thread only behind a writer-lock probe.
|
|
48
|
+
|
|
49
|
+
### Removed
|
|
50
|
+
|
|
51
|
+
- **BREAKING** Presence gate and the held-turn budget. `require_idle`, `away_after_seconds`, and `hook_reply_timeout_seconds` are gone rather than deprecated.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- A registered question must carry a commitment to the work each answer resumes, and that commitment must not name where the answer arrives.
|
|
56
|
+
|
|
57
|
+
## [0.4.0] - 2026-08-12
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- Agent-facing local logs.
|
|
62
|
+
- Crash-safe question continuation.
|
|
63
|
+
- Additive submission protocol updates required by the current CLI.
|
|
64
|
+
|
|
65
|
+
## [0.3.0] - 2026-08-10
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- Interactive app.
|
|
70
|
+
- Self-describing settings: a config schema that documents and validates itself, and a themed presentation layer for the banner, help, and config views.
|
|
71
|
+
|
|
72
|
+
## [0.2.1] - 2026-08-09
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- Question queue: asks never supersede each other; only replies supersede replies.
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
|
|
80
|
+
- `init` no longer stops at a routing pointer that has not fired yet.
|
|
81
|
+
|
|
82
|
+
## [0.2.0] - 2026-08-09
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
|
|
86
|
+
- **BREAKING** A reply request carries a set of 1–4 questions instead of a single kind/choices pair.
|
|
87
|
+
- **BREAKING** One `--choice` flag per answer. The comma delimiter is gone.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- Free-text or closed questions, multi-select, and typed answers beside choices.
|
|
92
|
+
- `--detail` / `--detail-file`, and `ask --form` for several questions as one form.
|
|
93
|
+
|
|
94
|
+
## [0.1.8] - 2026-08-08
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
|
|
98
|
+
- Human-facing product spelling is Notifai.
|
|
99
|
+
- Protocol dependency advanced to 0.1.2.
|
|
100
|
+
|
|
101
|
+
## [0.1.7] - 2026-08-05
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
|
|
105
|
+
- Release engineering and companion-install guidance.
|
|
106
|
+
|
|
107
|
+
## [0.1.6] - 2026-08-05
|
|
108
|
+
|
|
109
|
+
### Added
|
|
110
|
+
|
|
111
|
+
- Native skills setup composed into `init`.
|
|
112
|
+
|
|
113
|
+
## [0.1.5] - 2026-08-05
|
|
114
|
+
|
|
115
|
+
### Added
|
|
116
|
+
|
|
117
|
+
- Release pin verification.
|
|
118
|
+
|
|
119
|
+
## [0.1.4] - 2026-08-05
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
|
|
123
|
+
- Pinned skill installer source.
|
|
124
|
+
|
|
125
|
+
## [0.1.3] - 2026-08-05
|
|
126
|
+
|
|
127
|
+
### Changed
|
|
128
|
+
|
|
129
|
+
- Portable, time-bounded release checks.
|
|
130
|
+
|
|
131
|
+
## [0.1.2] - 2026-08-05
|
|
132
|
+
|
|
133
|
+
### Fixed
|
|
134
|
+
|
|
135
|
+
- CLI package installable from npm.
|
|
136
|
+
|
|
137
|
+
## [0.1.1] - 2026-08-05
|
|
138
|
+
|
|
139
|
+
### Added
|
|
140
|
+
|
|
141
|
+
- Receipt-backed setup.
|
|
142
|
+
|
|
143
|
+
## [0.1.0] - 2026-08-05
|
|
144
|
+
|
|
145
|
+
### Added
|
|
146
|
+
|
|
147
|
+
- First public release of the `notifai` CLI under Apache-2.0.
|
|
148
|
+
|
|
149
|
+
[unreleased]: https://github.com/Raidiant-io/notifai/compare/v1.0.0...HEAD
|
|
150
|
+
[1.0.0]: https://github.com/Raidiant-io/notifai/compare/v0.5.1...v1.0.0
|
|
151
|
+
[0.5.1]: https://github.com/Raidiant-io/notifai/compare/v0.5.0...v0.5.1
|
|
152
|
+
[0.5.0]: https://github.com/Raidiant-io/notifai/compare/v0.4.0...v0.5.0
|
|
153
|
+
[0.4.0]: https://github.com/Raidiant-io/notifai/compare/v0.3.0...v0.4.0
|
|
154
|
+
[0.3.0]: https://github.com/Raidiant-io/notifai/compare/v0.2.1...v0.3.0
|
|
155
|
+
[0.2.1]: https://github.com/Raidiant-io/notifai/compare/v0.2.0...v0.2.1
|
|
156
|
+
[0.2.0]: https://github.com/Raidiant-io/notifai/compare/v0.1.8...v0.2.0
|
|
157
|
+
[0.1.8]: https://github.com/Raidiant-io/notifai/compare/v0.1.7...v0.1.8
|
|
158
|
+
[0.1.7]: https://github.com/Raidiant-io/notifai/compare/v0.1.6...v0.1.7
|
|
159
|
+
[0.1.6]: https://github.com/Raidiant-io/notifai/compare/v0.1.5...v0.1.6
|
|
160
|
+
[0.1.5]: https://github.com/Raidiant-io/notifai/compare/v0.1.4...v0.1.5
|
|
161
|
+
[0.1.4]: https://github.com/Raidiant-io/notifai/compare/v0.1.3...v0.1.4
|
|
162
|
+
[0.1.3]: https://github.com/Raidiant-io/notifai/compare/v0.1.2...v0.1.3
|
|
163
|
+
[0.1.2]: https://github.com/Raidiant-io/notifai/compare/v0.1.1...v0.1.2
|
|
164
|
+
[0.1.1]: https://github.com/Raidiant-io/notifai/compare/v0.1.0...v0.1.1
|
|
165
|
+
[0.1.0]: https://github.com/Raidiant-io/notifai/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -12,6 +12,11 @@ npm install -g @raidiant/notifai
|
|
|
12
12
|
notifai init
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
If you do not want a global bin, `npx --yes @raidiant/notifai@<version>` is
|
|
16
|
+
supported. Pin the version. `hooks install` then writes that same pinned npx
|
|
17
|
+
invocation into the harness adapter. This is slower than a real install and
|
|
18
|
+
is not the default path.
|
|
19
|
+
|
|
15
20
|
`init` walks the setup one step at a time and tells you the single next
|
|
16
21
|
thing to do. Run it again after each step; it works out what remains.
|
|
17
22
|
|
|
@@ -55,10 +60,12 @@ For a harness that cannot resume an idle agent turn, `notifai send
|
|
|
55
60
|
notifai hooks install
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
Wires question routing into
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
Wires question routing into every supported harness detected on this
|
|
64
|
+
machine (Claude Code, Codex, Cursor, OpenCode). `notifai init` does the
|
|
65
|
+
same and, at a terminal, lets you keep the detected set, pick a subset,
|
|
66
|
+
or add one it did not see. This is what lets an agent's question reach
|
|
67
|
+
your phone without the agent having to cooperate — no question detection,
|
|
68
|
+
no state in a context window that compaction will eat.
|
|
62
69
|
|
|
63
70
|
Every harness definition calls one stable user-level adapter at
|
|
64
71
|
`~/.notifai/bin/hook-adapter`. Node, package manager, CLI version, checkout,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { EscalationDeliveryRoute } from './hooks.js';
|
|
2
|
+
/** Claude Code's currently observed inbox protocol. Unknown versions fail closed. */
|
|
3
|
+
export declare const CLAUDE_PEER_PROTOCOL = 1;
|
|
4
|
+
/** The first Claude Code release that publishes a session inbox socket. */
|
|
5
|
+
export declare const CLAUDE_MIN_INBOX_VERSION = "2.1.224";
|
|
6
|
+
/** macOS verifies own-child ancestry only while the posting process is alive. */
|
|
7
|
+
export declare const CLAUDE_POST_SEND_LIVENESS_MS = 8000;
|
|
8
|
+
export type ClaudeLiveStatus = 'idle' | 'busy';
|
|
9
|
+
export interface ClaudeSessionDescriptor {
|
|
10
|
+
pid: number;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
procStart: string;
|
|
15
|
+
version: string;
|
|
16
|
+
peerProtocol: number;
|
|
17
|
+
messagingSocketPath: string;
|
|
18
|
+
status: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ClaudeAgentObservation {
|
|
21
|
+
pid: number;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
status: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ClaudeWakeAdapters {
|
|
27
|
+
listAgents(): Promise<unknown>;
|
|
28
|
+
readDescriptor(pid: number): unknown;
|
|
29
|
+
sendSocket(socketPath: string, line: string): Promise<void>;
|
|
30
|
+
resume(sessionId: string, cwd: string, context: string): Promise<void>;
|
|
31
|
+
sleep(milliseconds: number): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export type ClaudeWakeObservation = {
|
|
34
|
+
state: 'live-idle' | 'live-busy';
|
|
35
|
+
descriptor: ClaudeSessionDescriptor;
|
|
36
|
+
} | {
|
|
37
|
+
state: 'stopped';
|
|
38
|
+
} | {
|
|
39
|
+
state: 'unknown';
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Observe one exact Claude Code session without guessing.
|
|
44
|
+
*
|
|
45
|
+
* `claude agents --json` is the liveness oracle: Claude Code itself validates
|
|
46
|
+
* each registry row against both PID and process start time before returning it.
|
|
47
|
+
* The descriptor supplies the socket and peer protocol, but is never trusted as
|
|
48
|
+
* proof that a process still owns the session.
|
|
49
|
+
*/
|
|
50
|
+
export declare function observeClaudeSession(sessionId: string, adapters: Pick<ClaudeWakeAdapters, 'listAgents' | 'readDescriptor'>): Promise<ClaudeWakeObservation>;
|
|
51
|
+
export type ClaudeInboxReadiness = {
|
|
52
|
+
state: 'ready';
|
|
53
|
+
socketPath: string;
|
|
54
|
+
version: string;
|
|
55
|
+
} | {
|
|
56
|
+
state: 'unavailable';
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether an answer could reach this exact Claude session over its inbox
|
|
61
|
+
* socket, decided from evidence already on disk.
|
|
62
|
+
*
|
|
63
|
+
* Read-only by construction: it reads the session descriptor and asks whether
|
|
64
|
+
* the socket file exists, and never connects to it. A diagnostic that delivers
|
|
65
|
+
* a message is not a diagnostic.
|
|
66
|
+
*
|
|
67
|
+
* Every negative answer here is a degradation, not a failure — the accepted
|
|
68
|
+
* journal still replays the answer at the session's next turn. What it buys is
|
|
69
|
+
* that the reason is nameable in advance instead of being discovered as
|
|
70
|
+
* silence. The common one is `--bare`, which binds no socket at all (and runs
|
|
71
|
+
* no hooks either, so nothing would have registered a question in the first
|
|
72
|
+
* place).
|
|
73
|
+
*/
|
|
74
|
+
export declare function inspectClaudeInbox(options: {
|
|
75
|
+
pid: number;
|
|
76
|
+
platform: NodeJS.Platform;
|
|
77
|
+
readDescriptor: (pid: number) => unknown;
|
|
78
|
+
socketExists: (socketPath: string) => boolean;
|
|
79
|
+
}): ClaudeInboxReadiness;
|
|
80
|
+
/**
|
|
81
|
+
* Claude's own-child delivery route.
|
|
82
|
+
*
|
|
83
|
+
* A successful socket write proves delivery to Claude Code, not model
|
|
84
|
+
* consumption — and this route reports exactly that, no more. It reports
|
|
85
|
+
* `delivered` all the same, because the write is the strongest acknowledgement
|
|
86
|
+
* this route can ever obtain: the message it posts starts a brand-new turn
|
|
87
|
+
* rather than continuing this one, so no later hook will confirm anything about
|
|
88
|
+
* it. An answer delivered once and settled is strictly better than an answer
|
|
89
|
+
* redelivered on every turn-end for ever.
|
|
90
|
+
*
|
|
91
|
+
* Every path that hands nothing over reports `held`, so the accepted journal
|
|
92
|
+
* replays it. Unknown state deliberately returns without throwing, so the
|
|
93
|
+
* journal becomes `hold-for-next-turn` instead of failing the hook.
|
|
94
|
+
*/
|
|
95
|
+
export declare function claudeWakeRoute(options: {
|
|
96
|
+
sessionId: string;
|
|
97
|
+
cwd: string;
|
|
98
|
+
sourcePid: number;
|
|
99
|
+
adapters?: ClaudeWakeAdapters;
|
|
100
|
+
}): EscalationDeliveryRoute;
|
|
101
|
+
export declare function systemClaudeWakeAdapters(env?: NodeJS.ProcessEnv): ClaudeWakeAdapters;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createConnection } from 'node:net';
|
|
6
|
+
/** Claude Code's currently observed inbox protocol. Unknown versions fail closed. */
|
|
7
|
+
export const CLAUDE_PEER_PROTOCOL = 1;
|
|
8
|
+
/** The first Claude Code release that publishes a session inbox socket. */
|
|
9
|
+
export const CLAUDE_MIN_INBOX_VERSION = '2.1.224';
|
|
10
|
+
/** The inbox socket is a Unix domain socket; no Windows implementation exists. */
|
|
11
|
+
const INBOX_PLATFORMS = new Set(['darwin', 'linux']);
|
|
12
|
+
/** macOS verifies own-child ancestry only while the posting process is alive. */
|
|
13
|
+
export const CLAUDE_POST_SEND_LIVENESS_MS = 8_000;
|
|
14
|
+
const LIVE_STATUSES = new Set(['idle', 'busy']);
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function parseAgent(value) {
|
|
19
|
+
if (!isRecord(value))
|
|
20
|
+
return null;
|
|
21
|
+
if (typeof value['pid'] !== 'number' ||
|
|
22
|
+
!Number.isInteger(value['pid']) ||
|
|
23
|
+
value['pid'] <= 0 ||
|
|
24
|
+
typeof value['sessionId'] !== 'string' ||
|
|
25
|
+
value['sessionId'] === '' ||
|
|
26
|
+
typeof value['startedAt'] !== 'number' ||
|
|
27
|
+
!Number.isFinite(value['startedAt']) ||
|
|
28
|
+
typeof value['status'] !== 'string') {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
pid: value['pid'],
|
|
33
|
+
sessionId: value['sessionId'],
|
|
34
|
+
startedAt: value['startedAt'],
|
|
35
|
+
status: value['status'],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parseDescriptor(value) {
|
|
39
|
+
if (!isRecord(value))
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof value['pid'] !== 'number' ||
|
|
42
|
+
!Number.isInteger(value['pid']) ||
|
|
43
|
+
value['pid'] <= 0 ||
|
|
44
|
+
typeof value['sessionId'] !== 'string' ||
|
|
45
|
+
value['sessionId'] === '' ||
|
|
46
|
+
typeof value['cwd'] !== 'string' ||
|
|
47
|
+
value['cwd'] === '' ||
|
|
48
|
+
typeof value['startedAt'] !== 'number' ||
|
|
49
|
+
!Number.isFinite(value['startedAt']) ||
|
|
50
|
+
typeof value['procStart'] !== 'string' ||
|
|
51
|
+
value['procStart'] === '' ||
|
|
52
|
+
typeof value['version'] !== 'string' ||
|
|
53
|
+
value['version'] === '' ||
|
|
54
|
+
typeof value['peerProtocol'] !== 'number' ||
|
|
55
|
+
!Number.isInteger(value['peerProtocol']) ||
|
|
56
|
+
typeof value['messagingSocketPath'] !== 'string' ||
|
|
57
|
+
value['messagingSocketPath'] === '' ||
|
|
58
|
+
typeof value['status'] !== 'string') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
pid: value['pid'],
|
|
63
|
+
sessionId: value['sessionId'],
|
|
64
|
+
cwd: value['cwd'],
|
|
65
|
+
startedAt: value['startedAt'],
|
|
66
|
+
procStart: value['procStart'],
|
|
67
|
+
version: value['version'],
|
|
68
|
+
peerProtocol: value['peerProtocol'],
|
|
69
|
+
messagingSocketPath: value['messagingSocketPath'],
|
|
70
|
+
status: value['status'],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Observe one exact Claude Code session without guessing.
|
|
75
|
+
*
|
|
76
|
+
* `claude agents --json` is the liveness oracle: Claude Code itself validates
|
|
77
|
+
* each registry row against both PID and process start time before returning it.
|
|
78
|
+
* The descriptor supplies the socket and peer protocol, but is never trusted as
|
|
79
|
+
* proof that a process still owns the session.
|
|
80
|
+
*/
|
|
81
|
+
export async function observeClaudeSession(sessionId, adapters) {
|
|
82
|
+
let listed;
|
|
83
|
+
try {
|
|
84
|
+
listed = await adapters.listAgents();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
state: 'unknown',
|
|
89
|
+
reason: `claude agents probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(listed)) {
|
|
93
|
+
return { state: 'unknown', reason: 'claude agents returned an invalid document' };
|
|
94
|
+
}
|
|
95
|
+
const matchingRaw = listed.filter((value) => isRecord(value) && value['sessionId'] === sessionId);
|
|
96
|
+
if (matchingRaw.length === 0)
|
|
97
|
+
return { state: 'stopped' };
|
|
98
|
+
if (matchingRaw.length !== 1) {
|
|
99
|
+
return { state: 'unknown', reason: 'claude agents returned duplicate session owners' };
|
|
100
|
+
}
|
|
101
|
+
const agent = parseAgent(matchingRaw[0]);
|
|
102
|
+
if (agent === null) {
|
|
103
|
+
return { state: 'unknown', reason: 'claude agents returned an invalid matching row' };
|
|
104
|
+
}
|
|
105
|
+
let descriptorRaw;
|
|
106
|
+
try {
|
|
107
|
+
descriptorRaw = adapters.readDescriptor(agent.pid);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
state: 'unknown',
|
|
112
|
+
reason: `Claude session descriptor could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const descriptor = parseDescriptor(descriptorRaw);
|
|
116
|
+
if (descriptor === null) {
|
|
117
|
+
return { state: 'unknown', reason: 'Claude session descriptor has an unknown shape' };
|
|
118
|
+
}
|
|
119
|
+
if (descriptor.pid !== agent.pid ||
|
|
120
|
+
descriptor.sessionId !== agent.sessionId ||
|
|
121
|
+
descriptor.startedAt !== agent.startedAt) {
|
|
122
|
+
return { state: 'unknown', reason: 'Claude liveness probe and descriptor disagree' };
|
|
123
|
+
}
|
|
124
|
+
if (descriptor.peerProtocol !== CLAUDE_PEER_PROTOCOL) {
|
|
125
|
+
return {
|
|
126
|
+
state: 'unknown',
|
|
127
|
+
reason: `unsupported Claude peer protocol ${descriptor.peerProtocol}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (!LIVE_STATUSES.has(agent.status)) {
|
|
131
|
+
return { state: 'unknown', reason: `Claude session status ${agent.status} is not wakeable` };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
state: agent.status === 'idle' ? 'live-idle' : 'live-busy',
|
|
135
|
+
descriptor,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Ordered comparison of dotted release numbers, ignoring any suffix. */
|
|
139
|
+
function versionAtLeast(version, minimum) {
|
|
140
|
+
const parse = (value) => value.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
141
|
+
const left = parse(version);
|
|
142
|
+
const right = parse(minimum);
|
|
143
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
144
|
+
const a = left[index] ?? 0;
|
|
145
|
+
const b = right[index] ?? 0;
|
|
146
|
+
if (a !== b)
|
|
147
|
+
return a > b;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether an answer could reach this exact Claude session over its inbox
|
|
153
|
+
* socket, decided from evidence already on disk.
|
|
154
|
+
*
|
|
155
|
+
* Read-only by construction: it reads the session descriptor and asks whether
|
|
156
|
+
* the socket file exists, and never connects to it. A diagnostic that delivers
|
|
157
|
+
* a message is not a diagnostic.
|
|
158
|
+
*
|
|
159
|
+
* Every negative answer here is a degradation, not a failure — the accepted
|
|
160
|
+
* journal still replays the answer at the session's next turn. What it buys is
|
|
161
|
+
* that the reason is nameable in advance instead of being discovered as
|
|
162
|
+
* silence. The common one is `--bare`, which binds no socket at all (and runs
|
|
163
|
+
* no hooks either, so nothing would have registered a question in the first
|
|
164
|
+
* place).
|
|
165
|
+
*/
|
|
166
|
+
export function inspectClaudeInbox(options) {
|
|
167
|
+
if (!INBOX_PLATFORMS.has(options.platform)) {
|
|
168
|
+
return {
|
|
169
|
+
state: 'unavailable',
|
|
170
|
+
reason: `Claude Code publishes no inbox socket on ${options.platform}; it exists on macOS and Linux only`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = options.readDescriptor(options.pid);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return {
|
|
179
|
+
state: 'unavailable',
|
|
180
|
+
reason: `no session descriptor for pid ${options.pid} (${err instanceof Error ? err.message : String(err)}); a session started with \`--bare\` binds no inbox socket`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const descriptor = parseDescriptor(raw);
|
|
184
|
+
if (descriptor === null) {
|
|
185
|
+
return {
|
|
186
|
+
state: 'unavailable',
|
|
187
|
+
reason: `the session descriptor for pid ${options.pid} has an unknown shape`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (descriptor.peerProtocol !== CLAUDE_PEER_PROTOCOL) {
|
|
191
|
+
return {
|
|
192
|
+
state: 'unavailable',
|
|
193
|
+
reason: `this session speaks inbox protocol ${descriptor.peerProtocol}, and only ${CLAUDE_PEER_PROTOCOL} is known; refusing to guess at an undocumented wire format`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (!versionAtLeast(descriptor.version, CLAUDE_MIN_INBOX_VERSION)) {
|
|
197
|
+
return {
|
|
198
|
+
state: 'unavailable',
|
|
199
|
+
reason: `Claude Code ${descriptor.version} is older than ${CLAUDE_MIN_INBOX_VERSION}, which is where the inbox socket starts`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (!options.socketExists(descriptor.messagingSocketPath)) {
|
|
203
|
+
return {
|
|
204
|
+
state: 'unavailable',
|
|
205
|
+
reason: `the descriptor names ${descriptor.messagingSocketPath}, but no socket exists there`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
state: 'ready',
|
|
210
|
+
socketPath: descriptor.messagingSocketPath,
|
|
211
|
+
version: descriptor.version,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function socketLine(context) {
|
|
215
|
+
return `${JSON.stringify({
|
|
216
|
+
type: 'user',
|
|
217
|
+
message: { role: 'user', content: context },
|
|
218
|
+
})}\n`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Claude's own-child delivery route.
|
|
222
|
+
*
|
|
223
|
+
* A successful socket write proves delivery to Claude Code, not model
|
|
224
|
+
* consumption — and this route reports exactly that, no more. It reports
|
|
225
|
+
* `delivered` all the same, because the write is the strongest acknowledgement
|
|
226
|
+
* this route can ever obtain: the message it posts starts a brand-new turn
|
|
227
|
+
* rather than continuing this one, so no later hook will confirm anything about
|
|
228
|
+
* it. An answer delivered once and settled is strictly better than an answer
|
|
229
|
+
* redelivered on every turn-end for ever.
|
|
230
|
+
*
|
|
231
|
+
* Every path that hands nothing over reports `held`, so the accepted journal
|
|
232
|
+
* replays it. Unknown state deliberately returns without throwing, so the
|
|
233
|
+
* journal becomes `hold-for-next-turn` instead of failing the hook.
|
|
234
|
+
*/
|
|
235
|
+
export function claudeWakeRoute(options) {
|
|
236
|
+
const adapters = options.adapters ?? systemClaudeWakeAdapters();
|
|
237
|
+
let sourceDescriptor = null;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = parseDescriptor(adapters.readDescriptor(options.sourcePid));
|
|
240
|
+
if (parsed !== null &&
|
|
241
|
+
parsed.sessionId === options.sessionId &&
|
|
242
|
+
parsed.pid === options.sourcePid) {
|
|
243
|
+
sourceDescriptor = parsed;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Delivery fails closed below; route construction must never break the hook.
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
kind: 'inbox-socket',
|
|
251
|
+
async deliver(event) {
|
|
252
|
+
const observation = await observeClaudeSession(options.sessionId, adapters);
|
|
253
|
+
if (observation.state === 'unknown') {
|
|
254
|
+
return {
|
|
255
|
+
notes: [`holding the accepted answer for the next turn: ${observation.reason}`],
|
|
256
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason: observation.reason },
|
|
257
|
+
acknowledgement: 'held',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (sourceDescriptor === null) {
|
|
261
|
+
const reason = 'the Stop-hook process cannot prove exact Claude session ownership';
|
|
262
|
+
return {
|
|
263
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
264
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
265
|
+
acknowledgement: 'held',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (observation.state === 'stopped') {
|
|
269
|
+
// Probe immediately before spawn. A prior descriptor or dead socket is
|
|
270
|
+
// never sufficient: resuming a live session creates a divergent ghost.
|
|
271
|
+
const confirmed = await observeClaudeSession(options.sessionId, adapters);
|
|
272
|
+
if (confirmed.state !== 'stopped') {
|
|
273
|
+
const reason = confirmed.state === 'unknown'
|
|
274
|
+
? confirmed.reason
|
|
275
|
+
: 'the Claude session became live before cold resume';
|
|
276
|
+
return {
|
|
277
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
278
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
279
|
+
acknowledgement: 'held',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
await adapters.resume(options.sessionId, sourceDescriptor.cwd, event.context);
|
|
283
|
+
return {
|
|
284
|
+
notes: ['cold-resumed the stopped Claude session with its accepted answer'],
|
|
285
|
+
log: { route: 'cold-resume', stage: 'delivered' },
|
|
286
|
+
acknowledgement: 'delivered',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (observation.descriptor.pid !== options.sourcePid ||
|
|
290
|
+
sourceDescriptor.startedAt !== observation.descriptor.startedAt) {
|
|
291
|
+
const reason = 'the Stop-hook process is not the observed exact Claude session child';
|
|
292
|
+
return {
|
|
293
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
294
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
295
|
+
acknowledgement: 'held',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
await adapters.sendSocket(observation.descriptor.messagingSocketPath, socketLine(event.context));
|
|
299
|
+
await adapters.sleep(CLAUDE_POST_SEND_LIVENESS_MS);
|
|
300
|
+
return {
|
|
301
|
+
notes: [
|
|
302
|
+
observation.state === 'live-idle'
|
|
303
|
+
? 'posted the accepted answer to the live Claude session'
|
|
304
|
+
: 'queued the accepted answer for the busy Claude session',
|
|
305
|
+
],
|
|
306
|
+
log: {
|
|
307
|
+
route: 'inbox-socket',
|
|
308
|
+
stage: 'delivered',
|
|
309
|
+
session_state: observation.state,
|
|
310
|
+
},
|
|
311
|
+
// The write completed: Claude Code holds the message. Nothing later
|
|
312
|
+
// reports on it, so this is where the journal settles.
|
|
313
|
+
acknowledgement: 'delivered',
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function runClaude(args, options = {}) {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
const child = spawn('claude', args, {
|
|
321
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
322
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
323
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
324
|
+
});
|
|
325
|
+
const stdout = [];
|
|
326
|
+
const stderr = [];
|
|
327
|
+
child.stdout?.on('data', (chunk) => stdout.push(Buffer.from(chunk)));
|
|
328
|
+
child.stderr?.on('data', (chunk) => stderr.push(Buffer.from(chunk)));
|
|
329
|
+
child.once('error', reject);
|
|
330
|
+
child.once('exit', (code, signal) => {
|
|
331
|
+
if (code === 0) {
|
|
332
|
+
resolve(Buffer.concat(stdout).toString());
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
reject(new Error(`claude ${args[0] ?? ''} exited ${code === null ? `on ${String(signal)}` : String(code)}: ${Buffer.concat(stderr).toString().trim()}`));
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function coldResumeEnvironment(env) {
|
|
340
|
+
const next = { ...env };
|
|
341
|
+
for (const key of Object.keys(next)) {
|
|
342
|
+
if (key === 'CLAUDECODE' || key === 'CLAUDE_PID' || key.startsWith('CLAUDE_CODE_')) {
|
|
343
|
+
delete next[key];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
export function systemClaudeWakeAdapters(env = process.env) {
|
|
349
|
+
return {
|
|
350
|
+
async listAgents() {
|
|
351
|
+
const output = await runClaude(['agents', '--json'], { env });
|
|
352
|
+
return JSON.parse(output);
|
|
353
|
+
},
|
|
354
|
+
readDescriptor(pid) {
|
|
355
|
+
const file = path.join(os.homedir(), '.claude', 'sessions', `${pid}.json`);
|
|
356
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
357
|
+
},
|
|
358
|
+
sendSocket(socketPath, line) {
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
const socket = createConnection(socketPath);
|
|
361
|
+
let settled = false;
|
|
362
|
+
const fail = (err) => {
|
|
363
|
+
if (settled)
|
|
364
|
+
return;
|
|
365
|
+
settled = true;
|
|
366
|
+
socket.destroy();
|
|
367
|
+
reject(err);
|
|
368
|
+
};
|
|
369
|
+
socket.once('error', fail);
|
|
370
|
+
socket.once('connect', () => {
|
|
371
|
+
socket.end(line, () => {
|
|
372
|
+
if (settled)
|
|
373
|
+
return;
|
|
374
|
+
settled = true;
|
|
375
|
+
resolve();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
async resume(sessionId, cwd, context) {
|
|
381
|
+
if (!existsSync(cwd))
|
|
382
|
+
throw new Error(`Claude session cwd no longer exists: ${cwd}`);
|
|
383
|
+
await runClaude([
|
|
384
|
+
'-p',
|
|
385
|
+
'--resume',
|
|
386
|
+
sessionId,
|
|
387
|
+
'--output-format',
|
|
388
|
+
'json',
|
|
389
|
+
context,
|
|
390
|
+
], { cwd, env: coldResumeEnvironment(env) });
|
|
391
|
+
},
|
|
392
|
+
sleep(milliseconds) {
|
|
393
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
//# sourceMappingURL=claude-wake.js.map
|