@raidiant/notifai 10.1.1 → 10.1.2
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 +20 -0
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2318
- package/src/commands-init.ts +0 -1055
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -875
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -317
- package/src/program.ts +0 -842
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Harness setup and recovery
|
|
2
|
+
|
|
3
|
+
The mechanics behind installing Notifai, routing questions to devices, and
|
|
4
|
+
working out why either is not happening. Read it when you are installing,
|
|
5
|
+
diagnosing, or recovering — not before.
|
|
6
|
+
|
|
7
|
+
- [Signing this machine in](#signing-this-machine-in)
|
|
8
|
+
- [Install deliberately](#install-deliberately)
|
|
9
|
+
- [Activation by harness](#activation-by-harness)
|
|
10
|
+
- [How the answer gets back to the agent](#how-the-answer-gets-back-to-the-agent)
|
|
11
|
+
- [Bounded recovery](#bounded-recovery)
|
|
12
|
+
- [Reading the record](#reading-the-record)
|
|
13
|
+
- [Settings and environment](#settings-and-environment)
|
|
14
|
+
|
|
15
|
+
## Signing this machine in
|
|
16
|
+
|
|
17
|
+
Run `notifai login` yourself. It opens a browser page only the user can approve.
|
|
18
|
+
|
|
19
|
+
In a headless or remote shell, `notifai login --no-open` prints the approval URL
|
|
20
|
+
instead of trying to open a browser — hand the user that URL. `--name <name>`
|
|
21
|
+
sets what the machine is called in their dashboard; the hostname is the default.
|
|
22
|
+
|
|
23
|
+
`notifai auth status --json` says whether this machine is paired.
|
|
24
|
+
`notifai auth access --json` says whether the account has access, including
|
|
25
|
+
access already requested and waiting on a person — not something to ask them to
|
|
26
|
+
do again. They fail differently and are worth separating before you report
|
|
27
|
+
either as broken.
|
|
28
|
+
`notifai logout` removes the stored credential.
|
|
29
|
+
|
|
30
|
+
## Install deliberately
|
|
31
|
+
|
|
32
|
+
Flagless `notifai init --json` needs no answer and closes everything before
|
|
33
|
+
this point. Hooks place files, so this is where a question belongs: ask setup
|
|
34
|
+
scope and whether they want questions routed, in the same structured question.
|
|
35
|
+
If they do, let init install and assess the hooks in their chosen scope. Never
|
|
36
|
+
tell the user to run setup commands themselves.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
notifai init --hooks --setup-scope <project|global> --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A machine-global Notifai skill is guidance, not routing evidence. The active
|
|
43
|
+
harness needs its own installed hook and a current session pointer.
|
|
44
|
+
|
|
45
|
+
Installed definitions call one stable user-level adapter at
|
|
46
|
+
`~/.notifai/bin/hook-adapter`. `hooks install` atomically retargets that adapter
|
|
47
|
+
to the current CLI while leaving definition bytes unchanged across Node/NVM,
|
|
48
|
+
package-manager, CLI-version, checkout, XDG directory, and Notifai preference
|
|
49
|
+
changes. Stop definitions differ by harness: Claude Code's runs asynchronously
|
|
50
|
+
with an explicit timeout above the longest answer window (POSIX hosts only — on
|
|
51
|
+
Windows it blocks like the others), and Codex declares the same full-window
|
|
52
|
+
timeout; prompt-submit and session-end retain fixed short limits on both.
|
|
53
|
+
Migrating an older Codex definition requires one unavoidable `/hooks` approval;
|
|
54
|
+
later upgrades must not require another.
|
|
55
|
+
|
|
56
|
+
`notifai ask --json` owns its admission check. On failure, branch on its stable
|
|
57
|
+
`code`, `check_id`, `exit_code`, and `remedy`; do not run a routine doctor pass
|
|
58
|
+
first. Historical UserPromptSubmit and Stop evidence remains part of that
|
|
59
|
+
fail-closed check. Ask exposes no `--session-id` override and will not guess one.
|
|
60
|
+
|
|
61
|
+
`hooks-wake-route` reports, without probing anything, whether an answer could
|
|
62
|
+
start a turn in this exact Agent Session on its own. It never blocks: when it
|
|
63
|
+
cannot, the answer is held and replayed at the Agent Session's next turn instead.
|
|
64
|
+
|
|
65
|
+
Notifai never writes trust approvals. If its diagnosis and Codex disagree,
|
|
66
|
+
`/hooks` is authoritative.
|
|
67
|
+
|
|
68
|
+
The prepared User message for this human-only action is:
|
|
69
|
+
|
|
70
|
+
> Open `/hooks` in Codex, approve or enable the Notifai handlers, then tell me
|
|
71
|
+
> when it is done. I will finish setup and verify a fresh session.
|
|
72
|
+
|
|
73
|
+
## Activation by harness
|
|
74
|
+
|
|
75
|
+
- **Claude Code:** run the installer if needed, start one fresh Agent Session,
|
|
76
|
+
send one prompt, then run `notifai doctor`. An already-running Agent Session cannot
|
|
77
|
+
receive newly installed `SessionStart` context. If SessionStart is absent,
|
|
78
|
+
reinstall the current hooks and start a fresh Agent Session; UserPromptSubmit
|
|
79
|
+
records presence and question lifecycle only and never substitutes for
|
|
80
|
+
lifecycle activation. Claude's SubagentStart gives ordinary workers the
|
|
81
|
+
reporting-only context; explicit textual delegation makes a worker load the
|
|
82
|
+
skill and guidance as the new Notification Request owner.
|
|
83
|
+
- **Codex:** run `notifai hooks install --harness codex`. If `hooks-trust`
|
|
84
|
+
fails, open `/hooks` in Codex and approve or enable the Notifai handlers.
|
|
85
|
+
Then start one fresh Agent Session, send one prompt, and run `notifai doctor`. If
|
|
86
|
+
SessionStart is absent, reinstall the current hooks and start a fresh Agent Session;
|
|
87
|
+
UserPromptSubmit does not activate it. Codex SubagentStart uses the same
|
|
88
|
+
reporting-only worker contract and explicit textual delegation rule as
|
|
89
|
+
Claude. Codex resolves project hooks from the main repository. In a
|
|
90
|
+
linked worktree, the installer writes the shared file to
|
|
91
|
+
the main checkout and creates the project-layer `.codex` directory in the
|
|
92
|
+
current worktree; run it once in each new worktree.
|
|
93
|
+
- **Cursor:** start one fresh conversation, send one prompt, and let the first
|
|
94
|
+
completed or errored turn finish. Cursor's `SessionStart` context is currently
|
|
95
|
+
lossy, so one visible synthetic follow-up activates Notifai through its native
|
|
96
|
+
Stop contract; cancellation does not trigger it, and a live question
|
|
97
|
+
continuation takes priority. Then run `notifai doctor`. The agent shell does
|
|
98
|
+
not create a separately activated context for delegated work: it remains
|
|
99
|
+
under the parent Agent Session and its explicit Notification Request ownership. It
|
|
100
|
+
does not expose the exact conversation id needed to prove which concurrent Agent Session
|
|
101
|
+
invoked `notifai ask`, so asynchronous ask fails closed. Use blocking
|
|
102
|
+
`notifai send --reply` for questions.
|
|
103
|
+
- **OpenCode:** restart after installation because plugins load at startup,
|
|
104
|
+
then start one fresh Agent Session, send one prompt, and run `notifai doctor`.
|
|
105
|
+
Notifai owns its generated plugin file and will not overwrite a foreign one.
|
|
106
|
+
The plugin treats a session with `parentID` as a worker. When relationship
|
|
107
|
+
lookup fails or returns unusable data it also fails safe as a non-sending
|
|
108
|
+
worker; only a proven parent Agent Session receives owner context. Explicit textual
|
|
109
|
+
delegation promotes that worker through the same skill-and-guidance rule.
|
|
110
|
+
OpenCode has no locally proven exactly-once continuation after `session.idle`,
|
|
111
|
+
so `notifai ask` fails closed instead of accepting an answer into a void.
|
|
112
|
+
Use a blocking `notifai send --reply` question when its answer must return to
|
|
113
|
+
the agent without another human prompt.
|
|
114
|
+
- **OpenClaw:** restart the Gateway after installation because plugins load at
|
|
115
|
+
startup, then start one fresh Agent Session, send one prompt, and run
|
|
116
|
+
`notifai doctor`. Notifai owns its generated Gateway plugin and will not
|
|
117
|
+
overwrite a foreign one. Exact Agent Session identity is the OpenClaw
|
|
118
|
+
`sessionKey`; `sessionId` is a rotating generation and is never used as the
|
|
119
|
+
session id. A session key containing `:subagent:` or an ACP nested context
|
|
120
|
+
is a worker. Missing identity fails safe as a non-sending worker; only a
|
|
121
|
+
proven parent Agent Session receives owner context. Explicit textual
|
|
122
|
+
delegation promotes that worker through the same skill-and-guidance rule.
|
|
123
|
+
OpenClaw has no locally proven exactly-once continuation after `agent_end`,
|
|
124
|
+
so `notifai ask` fails closed instead of accepting an answer into a void.
|
|
125
|
+
Use a blocking `notifai send --reply` question when its answer must return to
|
|
126
|
+
the agent without another human prompt. Do not treat `/stop` as turn-end.
|
|
127
|
+
- **Hermes:** classic CLI on the local terminal backend can send. Notifai reads
|
|
128
|
+
exact `HERMES_SESSION_ID` and derives git branch and worktree from the actual
|
|
129
|
+
invocation cwd. Do not run `notifai hooks install --harness hermes` — managed
|
|
130
|
+
setup, lifecycle activation, and Question Routing are unsupported. Use a
|
|
131
|
+
blocking `notifai send --reply` question. Install the Notifai skill through
|
|
132
|
+
`npx skills`; no Hermes plugin may vendor a copy. Nested inherited harness
|
|
133
|
+
markers fail closed. TUI, gateway, API, ACP, and remote backends are not
|
|
134
|
+
this baseline.
|
|
135
|
+
|
|
136
|
+
Do not claim managed hook, activation, or Question Routing support for a
|
|
137
|
+
harness that is absent from `notifai hooks install --help`. Classic Hermes CLI
|
|
138
|
+
can send with Source Context without those.
|
|
139
|
+
|
|
140
|
+
## How the answer gets back to the agent
|
|
141
|
+
|
|
142
|
+
The session that registered a question owns the answer's return. The last
|
|
143
|
+
meter differs per harness:
|
|
144
|
+
|
|
145
|
+
- **Claude Code:** the Stop hook is asynchronous. It returns at once, so the
|
|
146
|
+
turn is never held and the terminal stays the user's, and the same process
|
|
147
|
+
keeps waiting out of band for the complete answer window. When the answer
|
|
148
|
+
arrives it is posted to that Agent Session's own inbox socket: an idle Agent
|
|
149
|
+
Session starts a new turn with it, and a busy one receives it when its current
|
|
150
|
+
turn ends. An Agent Session that is provably gone is cold-resumed instead —
|
|
151
|
+
never one whose liveness probe cannot rule it out.
|
|
152
|
+
- **Codex:** the Stop hook is the waiter. It holds until the answer arrives or
|
|
153
|
+
the complete answer window ends, then continues the session by returning
|
|
154
|
+
`decision: block`.
|
|
155
|
+
- **Crash recovery:** the answer journal protects an accepted answer if an
|
|
156
|
+
owner process or its route fails. It is not the normal last meter for an
|
|
157
|
+
unexpired question.
|
|
158
|
+
|
|
159
|
+
An accepted answer is never dropped and never delivered twice, so a question
|
|
160
|
+
that has not come back yet is still coming: do not re-ask it.
|
|
161
|
+
|
|
162
|
+
At the `ask_grace_seconds` default of `0`, the question reaches devices as soon
|
|
163
|
+
as the asking turn ends. A positive value keeps it in the terminal for that
|
|
164
|
+
long first, so an answer typed there wins without a notification ever leaving.
|
|
165
|
+
`reply_window_seconds` then controls how long the answer is accepted and how
|
|
166
|
+
long Question Routing keeps an exact return path to this Agent Session. The
|
|
167
|
+
grace window is skipped when a question from this Agent Session is already
|
|
168
|
+
waiting on the user's devices: they have been interrupted already, and holding
|
|
169
|
+
the second question back would only delay it.
|
|
170
|
+
|
|
171
|
+
## Bounded recovery
|
|
172
|
+
|
|
173
|
+
Follow the exact `notifai doctor` diagnostic. Common recovery is one repair,
|
|
174
|
+
one fresh activation, and one new doctor check. Stop if the current pointer
|
|
175
|
+
belongs to another active session or if the hook still has not fired; ask the
|
|
176
|
+
user or coordinator instead of retrying indefinitely.
|
|
177
|
+
|
|
178
|
+
If a companion device is missing, ask the user to open a supported companion
|
|
179
|
+
build, sign in, and grant notification permission. Do not emulate that, and do
|
|
180
|
+
not treat Provider Acceptance as Companion Receipt proof.
|
|
181
|
+
|
|
182
|
+
To stop routing questions from this project, `notifai hooks uninstall`; add
|
|
183
|
+
`--global` to remove a machine-wide install, and `--harness <name>` to name one
|
|
184
|
+
when several are wired.
|
|
185
|
+
|
|
186
|
+
## Reading the record
|
|
187
|
+
|
|
188
|
+
`notifai logs` narrows several ways, and they compose:
|
|
189
|
+
|
|
190
|
+
- `--request <id>` · `--run <id>` · `--session <id>` — one notification, one
|
|
191
|
+
invocation, one Agent Session
|
|
192
|
+
- `--event <name>` (repeatable) · `--grep <text>` · `--level error`
|
|
193
|
+
- `--since 10m|2h|1d|<ISO 8601>` · `-n <count>` · `--all` · `--project <id>` ·
|
|
194
|
+
`--all-projects`
|
|
195
|
+
- `--json` for one record per line, `--path` for where the files are
|
|
196
|
+
|
|
197
|
+
`--clear` deletes the user's local record. It is theirs, and nothing else keeps
|
|
198
|
+
a copy — do not run it to tidy up.
|
|
199
|
+
|
|
200
|
+
## Settings and environment
|
|
201
|
+
|
|
202
|
+
`notifai config explain <key> [--json]` gives the full explanation of one
|
|
203
|
+
setting; `notifai config show --explain` includes advanced keys and the file
|
|
204
|
+
each value came from. Beyond the usual scopes, `--session <id>` writes a
|
|
205
|
+
preference that lasts only for one Agent Session.
|
|
206
|
+
|
|
207
|
+
`ask_notifications` is the setting that turns question routing off for a scope;
|
|
208
|
+
`ask_grace_seconds` is the terminal-first window described above;
|
|
209
|
+
`reply_window_seconds` is how long an answer is still accepted, a day by
|
|
210
|
+
default and up to three.
|
|
211
|
+
|
|
212
|
+
Those are three different controls and only the last one decides whether an
|
|
213
|
+
answer is still wanted. Question Routing owns that complete window: Claude Code
|
|
214
|
+
waits out of band and wakes the Agent Session, while Codex keeps the asking turn
|
|
215
|
+
held. The journal is crash recovery, not the ordinary delivery path.
|
|
216
|
+
|
|
217
|
+
`NOTIFAI_NO_INPUT=1` guarantees no command will ever prompt, which is what you
|
|
218
|
+
want in CI or any shell with nobody at it. `NOTIFAI_CREDENTIALS=file` stores the
|
|
219
|
+
machine credential in a plaintext file rather than the OS keychain — only when
|
|
220
|
+
the user has asked for it, and never on a shared machine.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidiant/notifai",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Send native device notifications from agents and local programs",
|
|
6
6
|
"bin": {
|
|
@@ -8,20 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
-
"src",
|
|
12
|
-
"tsconfig.json",
|
|
13
11
|
"CHANGELOG.md"
|
|
14
12
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json && node ../../scripts/chmod-cli-bin.mjs",
|
|
17
|
-
"pretest": "pnpm run build",
|
|
18
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
|
-
"test": "node ../../scripts/run-cli-tests.mjs",
|
|
20
|
-
"prepack": "pnpm run build"
|
|
21
|
-
},
|
|
22
13
|
"dependencies": {
|
|
23
14
|
"@clack/prompts": "^1.7.0",
|
|
24
|
-
"@raidiant/notifai-protocol": "6.0.
|
|
15
|
+
"@raidiant/notifai-protocol": "6.0.3",
|
|
25
16
|
"commander": "^14.0.0",
|
|
26
17
|
"picocolors": "^1.1.1",
|
|
27
18
|
"smol-toml": "^1.4.0"
|
|
@@ -31,7 +22,7 @@
|
|
|
31
22
|
"vitest": "^3.2.0"
|
|
32
23
|
},
|
|
33
24
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
25
|
+
"node": ">=20.12.0"
|
|
35
26
|
},
|
|
36
27
|
"license": "Apache-2.0",
|
|
37
28
|
"author": "Rafael Vidaurre",
|
|
@@ -58,5 +49,11 @@
|
|
|
58
49
|
"ai",
|
|
59
50
|
"claude",
|
|
60
51
|
"codex"
|
|
61
|
-
]
|
|
62
|
-
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json && node ../../scripts/copy-shipped-skill.mjs && node ../../scripts/chmod-cli-bin.mjs",
|
|
55
|
+
"pretest": "pnpm run build",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
57
|
+
"test": "node ../../scripts/run-cli-tests.mjs"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/atomic-file.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
chmodSync,
|
|
4
|
-
closeSync,
|
|
5
|
-
fsyncSync,
|
|
6
|
-
lstatSync,
|
|
7
|
-
mkdirSync,
|
|
8
|
-
openSync,
|
|
9
|
-
renameSync,
|
|
10
|
-
unlinkSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
|
|
15
|
-
export interface AtomicWriteOptions {
|
|
16
|
-
/** Mode for a newly created file. Existing regular files keep their mode. */
|
|
17
|
-
mode?: number
|
|
18
|
-
/** False when a managed executable must keep the requested mode on repair. */
|
|
19
|
-
preserveMode?: boolean
|
|
20
|
-
/** Reject a target owned by another user instead of replacing it. */
|
|
21
|
-
requireCurrentUserOwner?: boolean
|
|
22
|
-
/**
|
|
23
|
-
* POSIX directory mode applied to the parent. Defaults to 0o700 when
|
|
24
|
-
* `requireCurrentUserOwner` is set so credential and config dirs match the
|
|
25
|
-
* hook-adapter and log directories.
|
|
26
|
-
*/
|
|
27
|
-
directoryMode?: number
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Replace one regular file without exposing a truncated intermediate state.
|
|
32
|
-
*
|
|
33
|
-
* The temporary file is a unique sibling, so rename is atomic on every
|
|
34
|
-
* supported filesystem. The contents are flushed before the rename, and a
|
|
35
|
-
* symlink target is refused instead of silently writing through it.
|
|
36
|
-
*/
|
|
37
|
-
export function atomicWriteFileSync(
|
|
38
|
-
file: string,
|
|
39
|
-
contents: string,
|
|
40
|
-
options: AtomicWriteOptions = {},
|
|
41
|
-
): void {
|
|
42
|
-
const directory = path.dirname(file)
|
|
43
|
-
const requireCurrentUserOwner = options.requireCurrentUserOwner ?? false
|
|
44
|
-
const directoryMode = options.directoryMode ?? (requireCurrentUserOwner ? 0o700 : undefined)
|
|
45
|
-
const parent = ensureDirectory(directory, {
|
|
46
|
-
requireCurrentUserOwner,
|
|
47
|
-
...(directoryMode === undefined ? {} : { mode: directoryMode }),
|
|
48
|
-
})
|
|
49
|
-
const target = targetMetadata(
|
|
50
|
-
file,
|
|
51
|
-
options.mode ?? 0o600,
|
|
52
|
-
options.preserveMode ?? true,
|
|
53
|
-
options.requireCurrentUserOwner ?? false,
|
|
54
|
-
)
|
|
55
|
-
const temp = path.join(
|
|
56
|
-
directory,
|
|
57
|
-
`.${path.basename(file)}.notifai-${process.pid}-${randomBytes(6).toString('hex')}.tmp`,
|
|
58
|
-
)
|
|
59
|
-
let handle: number | undefined
|
|
60
|
-
try {
|
|
61
|
-
handle = openSync(temp, 'wx', target.mode)
|
|
62
|
-
writeFileSync(handle, contents)
|
|
63
|
-
fsyncSync(handle)
|
|
64
|
-
closeSync(handle)
|
|
65
|
-
handle = undefined
|
|
66
|
-
chmodSync(temp, target.mode)
|
|
67
|
-
assertSameDirectory(directory, parent)
|
|
68
|
-
assertUnchangedTarget(file, target)
|
|
69
|
-
renameSync(temp, file)
|
|
70
|
-
syncDirectory(directory)
|
|
71
|
-
} catch (err) {
|
|
72
|
-
if (handle !== undefined) closeSync(handle)
|
|
73
|
-
try {
|
|
74
|
-
unlinkSync(temp)
|
|
75
|
-
} catch {
|
|
76
|
-
// Preserve the original error; the temp may not have been created.
|
|
77
|
-
}
|
|
78
|
-
throw err
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Create (or tighten) a directory that must not be world-readable.
|
|
84
|
-
*
|
|
85
|
-
* `mkdir` mode applies only to a newly created leaf, so an existing 0755
|
|
86
|
-
* credential directory would otherwise stay world-listable. POSIX chmod of
|
|
87
|
-
* the leaf is the matching repair; Windows has no equivalent mode bits.
|
|
88
|
-
*/
|
|
89
|
-
export function ensurePrivateDirectory(directory: string): void {
|
|
90
|
-
ensureDirectory(directory, { mode: 0o700, requireCurrentUserOwner: false })
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function ensureDirectory(
|
|
94
|
-
directory: string,
|
|
95
|
-
options: { mode?: number; requireCurrentUserOwner: boolean },
|
|
96
|
-
): { dev: number; ino: number } {
|
|
97
|
-
if (options.mode === undefined) mkdirSync(directory, { recursive: true })
|
|
98
|
-
else mkdirSync(directory, { recursive: true, mode: options.mode })
|
|
99
|
-
const parent = safeDirectory(directory, options.requireCurrentUserOwner)
|
|
100
|
-
if (options.mode !== undefined && process.platform !== 'win32') {
|
|
101
|
-
chmodSync(directory, options.mode)
|
|
102
|
-
}
|
|
103
|
-
return parent
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Persist the published directory entry where the host supports directory fsync. */
|
|
107
|
-
function syncDirectory(directory: string): void {
|
|
108
|
-
if (process.platform === 'win32') return
|
|
109
|
-
const handle = openSync(directory, 'r')
|
|
110
|
-
try {
|
|
111
|
-
fsyncSync(handle)
|
|
112
|
-
} finally {
|
|
113
|
-
closeSync(handle)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
|
|
118
|
-
interface TargetMetadata {
|
|
119
|
-
mode: number
|
|
120
|
-
identity: { dev: number; ino: number } | null
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function targetMetadata(
|
|
124
|
-
file: string,
|
|
125
|
-
fallback: number,
|
|
126
|
-
preserveMode: boolean,
|
|
127
|
-
requireCurrentUserOwner: boolean,
|
|
128
|
-
): TargetMetadata {
|
|
129
|
-
let stat
|
|
130
|
-
try {
|
|
131
|
-
stat = lstatSync(file)
|
|
132
|
-
} catch (err) {
|
|
133
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
134
|
-
return { mode: fallback, identity: null }
|
|
135
|
-
}
|
|
136
|
-
throw err
|
|
137
|
-
}
|
|
138
|
-
if (stat.isSymbolicLink()) {
|
|
139
|
-
throw new Error(
|
|
140
|
-
`${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
|
|
141
|
-
'or use a different state directory.',
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
|
|
145
|
-
if (requireCurrentUserOwner) assertCurrentUserOwns(file, stat.uid)
|
|
146
|
-
return {
|
|
147
|
-
mode: preserveMode ? stat.mode & 0o777 : fallback,
|
|
148
|
-
identity: { dev: stat.dev, ino: stat.ino },
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function assertUnchangedTarget(file: string, target: TargetMetadata): void {
|
|
153
|
-
let current
|
|
154
|
-
try {
|
|
155
|
-
current = lstatSync(file)
|
|
156
|
-
} catch (err) {
|
|
157
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT' && target.identity === null) return
|
|
158
|
-
throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
|
|
159
|
-
}
|
|
160
|
-
if (
|
|
161
|
-
target.identity === null ||
|
|
162
|
-
current.dev !== target.identity.dev ||
|
|
163
|
-
current.ino !== target.identity.ino
|
|
164
|
-
) {
|
|
165
|
-
throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function safeDirectory(
|
|
170
|
-
directory: string,
|
|
171
|
-
requireCurrentUserOwner: boolean,
|
|
172
|
-
): { dev: number; ino: number } {
|
|
173
|
-
const stat = lstatSync(directory)
|
|
174
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
175
|
-
throw new Error(`${directory} is not a regular directory; refusing to write inside it.`)
|
|
176
|
-
}
|
|
177
|
-
if (requireCurrentUserOwner) assertCurrentUserOwns(directory, stat.uid)
|
|
178
|
-
return { dev: stat.dev, ino: stat.ino }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function assertSameDirectory(directory: string, expected: { dev: number; ino: number }): void {
|
|
182
|
-
const current = lstatSync(directory)
|
|
183
|
-
if (
|
|
184
|
-
current.isSymbolicLink() ||
|
|
185
|
-
!current.isDirectory() ||
|
|
186
|
-
current.dev !== expected.dev ||
|
|
187
|
-
current.ino !== expected.ino
|
|
188
|
-
) {
|
|
189
|
-
throw new Error(`${directory} changed during the write; refusing to publish the file.`)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function assertCurrentUserOwns(file: string, owner: number): void {
|
|
194
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
195
|
-
if (uid !== undefined && owner !== uid) {
|
|
196
|
-
throw new Error(`${file} is owned by uid ${owner}, not the current user; refusing to replace it.`)
|
|
197
|
-
}
|
|
198
|
-
}
|