nterminal 1.3.4 → 1.3.5

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.
Files changed (113) hide show
  1. package/bin/nterminal.js +8 -3
  2. package/dist/client/assets/MarkdownPreview-BIrwUAaa.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-H8ZsISc6.js → TranscriptMarkdownBody-CI0ztvMX.js} +1 -1
  4. package/dist/client/assets/{index-DxfBq-4P.js → index-EN8ngMgv.js} +1 -1
  5. package/dist/client/assets/index-JQJ1Eet1.js +58 -0
  6. package/dist/client/assets/index-xkL4h3qt.css +1 -0
  7. package/dist/client/index.html +2 -2
  8. package/dist/scripts/hooks.js +83 -0
  9. package/dist/scripts/hooks.js.map +1 -0
  10. package/dist/server/accounts/accountService.d.ts +23 -1
  11. package/dist/server/accounts/accountService.js +235 -103
  12. package/dist/server/accounts/accountService.js.map +1 -1
  13. package/dist/server/accounts/providerRequests.d.ts +12 -0
  14. package/dist/server/accounts/providerRequests.js +159 -0
  15. package/dist/server/accounts/providerRequests.js.map +1 -0
  16. package/dist/server/agent/agentRoutes.js +11 -0
  17. package/dist/server/agent/agentRoutes.js.map +1 -1
  18. package/dist/server/hooks/helperJournal.d.ts +16 -0
  19. package/dist/server/hooks/helperJournal.js +74 -0
  20. package/dist/server/hooks/helperJournal.js.map +1 -0
  21. package/dist/server/hooks/hookBinding.d.ts +39 -0
  22. package/dist/server/hooks/hookBinding.js +91 -0
  23. package/dist/server/hooks/hookBinding.js.map +1 -0
  24. package/dist/server/hooks/hookFiles.d.ts +24 -0
  25. package/dist/server/hooks/hookFiles.js +160 -0
  26. package/dist/server/hooks/hookFiles.js.map +1 -0
  27. package/dist/server/hooks/hookInstallation.d.ts +79 -0
  28. package/dist/server/hooks/hookInstallation.js +978 -0
  29. package/dist/server/hooks/hookInstallation.js.map +1 -0
  30. package/dist/server/hooks/hookProcess.d.ts +5 -0
  31. package/dist/server/hooks/hookProcess.js +85 -0
  32. package/dist/server/hooks/hookProcess.js.map +1 -0
  33. package/dist/server/hooks/hookReceiver.d.ts +66 -0
  34. package/dist/server/hooks/hookReceiver.js +549 -0
  35. package/dist/server/hooks/hookReceiver.js.map +1 -0
  36. package/dist/server/hooks/lifecycle.d.ts +40 -0
  37. package/dist/server/hooks/lifecycle.js +200 -0
  38. package/dist/server/hooks/lifecycle.js.map +1 -0
  39. package/dist/server/hooks/publicationLock.d.ts +3 -0
  40. package/dist/server/hooks/publicationLock.js +137 -0
  41. package/dist/server/hooks/publicationLock.js.map +1 -0
  42. package/dist/server/hooks/verifiedLock.d.ts +24 -0
  43. package/dist/server/hooks/verifiedLock.js +207 -0
  44. package/dist/server/hooks/verifiedLock.js.map +1 -0
  45. package/dist/server/http.d.ts +4 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/http.js.map +1 -1
  48. package/dist/server/index.js +69 -2
  49. package/dist/server/index.js.map +1 -1
  50. package/dist/server/notifications/agentCompletionMonitor.d.ts +16 -1
  51. package/dist/server/notifications/agentCompletionMonitor.js +261 -47
  52. package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
  53. package/dist/server/notifications/completionNotificationRouter.d.ts +2 -0
  54. package/dist/server/notifications/completionNotificationRouter.js +9 -0
  55. package/dist/server/notifications/completionNotificationRouter.js.map +1 -1
  56. package/dist/server/routes/accountRoutes.js +11 -0
  57. package/dist/server/routes/accountRoutes.js.map +1 -1
  58. package/dist/server/routes/activityRoutes.d.ts +13 -0
  59. package/dist/server/routes/activityRoutes.js +165 -0
  60. package/dist/server/routes/activityRoutes.js.map +1 -0
  61. package/dist/server/routes/agentManagementRoutes.js +8 -1
  62. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  63. package/dist/server/routes/hookRoutes.d.ts +16 -0
  64. package/dist/server/routes/hookRoutes.js +77 -0
  65. package/dist/server/routes/hookRoutes.js.map +1 -0
  66. package/dist/server/routes/terminalRoutes.js +4 -0
  67. package/dist/server/routes/terminalRoutes.js.map +1 -1
  68. package/dist/server/routes/transcriptQuery.d.ts +2 -1
  69. package/dist/server/routes/transcriptQuery.js +3 -1
  70. package/dist/server/routes/transcriptQuery.js.map +1 -1
  71. package/dist/server/terminal/PtyAdapter.d.ts +18 -0
  72. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  73. package/dist/server/terminal/TerminalManager.d.ts +61 -0
  74. package/dist/server/terminal/TerminalManager.js +665 -12
  75. package/dist/server/terminal/TerminalManager.js.map +1 -1
  76. package/dist/server/terminal/TmuxPtyAdapter.d.ts +14 -2
  77. package/dist/server/terminal/TmuxPtyAdapter.js +99 -15
  78. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  79. package/dist/server/terminal/activityChannel.d.ts +57 -0
  80. package/dist/server/terminal/activityChannel.js +166 -0
  81. package/dist/server/terminal/activityChannel.js.map +1 -0
  82. package/dist/server/terminal/agentTranscriptStatus.d.ts +12 -0
  83. package/dist/server/terminal/agentTranscriptStatus.js +78 -7
  84. package/dist/server/terminal/agentTranscriptStatus.js.map +1 -1
  85. package/dist/server/terminal/codexTranscriptSource.d.ts +2 -0
  86. package/dist/server/terminal/codexTranscriptSource.js +41 -37
  87. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  88. package/dist/server/terminal/subagentSources.js +2 -1
  89. package/dist/server/terminal/subagentSources.js.map +1 -1
  90. package/dist/server/terminal/transcriptHeader.d.ts +85 -0
  91. package/dist/server/terminal/transcriptHeader.js +376 -0
  92. package/dist/server/terminal/transcriptHeader.js.map +1 -0
  93. package/dist/server/terminal/transcriptHistory.d.ts +16 -2
  94. package/dist/server/terminal/transcriptHistory.js +120 -21
  95. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  96. package/dist/server/terminal/transcriptStore.d.ts +6 -0
  97. package/dist/server/terminal/transcriptStore.js +222 -26
  98. package/dist/server/terminal/transcriptStore.js.map +1 -1
  99. package/dist/shared/activityProbe.d.ts +10 -0
  100. package/dist/shared/activityProbe.js +24 -0
  101. package/dist/shared/activityProbe.js.map +1 -0
  102. package/dist/shared/protocol.d.ts +45 -0
  103. package/dist/shared/protocol.js.map +1 -1
  104. package/dist/shared/types.d.ts +9 -1
  105. package/docs/features.md +10 -0
  106. package/docs/provider-hooks.md +287 -0
  107. package/docs/terminal-history.md +10 -0
  108. package/package.json +2 -1
  109. package/scripts/nterminal-hook.cjs +171 -0
  110. package/scripts/nterminalctl +26 -2
  111. package/dist/client/assets/MarkdownPreview-DjDO4lEl.js +0 -1
  112. package/dist/client/assets/index-CIYazAGv.css +0 -1
  113. package/dist/client/assets/index-DwvLN_WJ.js +0 -56
@@ -0,0 +1,978 @@
1
+ import { constants } from 'node:fs';
2
+ import { access, lstat, readFile, readdir, realpath } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { HookControlError as InstallationError, ensureDirectory, missing, snapshot, replace } from './hookFiles.js';
7
+ import { acquireVerifiedLock } from './verifiedLock.js';
8
+ import { HelperJournal } from './helperJournal.js';
9
+ // Subagent events are not registered: the runtime ignores child lifecycle events.
10
+ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'Stop', 'Interrupt', 'SessionEnd'];
11
+ const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'Stop', 'StopFailure', 'PostToolUseFailure', 'Notification', 'SessionEnd'];
12
+ // NTerminal never uses a hook response. Only turn/session boundaries stay synchronous:
13
+ // they are infrequent, ordered, and the provider may exit right after them (Codex
14
+ // also rejects async SessionEnd). Per-tool and notification events never block.
15
+ const ASYNC_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', 'Notification', 'Interrupt']);
16
+ // Definitions written by earlier NTerminal versions; recognized only for removal/upgrade.
17
+ const LEGACY_BASE = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'Stop', 'SubagentStart', 'SubagentStop'];
18
+ const LEGACY_EVENTS = {
19
+ codex: [...LEGACY_BASE, 'Interrupt', 'SessionEnd'],
20
+ claude: [...LEGACY_BASE, 'StopFailure', 'PostToolUseFailure', 'Notification', 'SessionEnd']
21
+ };
22
+ const NOTIFY_BEGIN = '# BEGIN NTerminal notify';
23
+ const NOTIFY_END = '# END NTerminal notify';
24
+ export class HookInstallation {
25
+ options;
26
+ hooksDir;
27
+ lexicalHooksDir;
28
+ helperPath;
29
+ helperOwnershipPath;
30
+ helperJournal;
31
+ endpointPath;
32
+ nodeExecutable;
33
+ homes;
34
+ resolved = {};
35
+ constructor(options) {
36
+ this.options = options;
37
+ const home = options.homeDir ?? os.homedir();
38
+ const base = options.baseDir ?? process.cwd();
39
+ this.lexicalHooksDir = path.resolve(base, options.stateDir, 'hooks');
40
+ this.hooksDir = this.lexicalHooksDir;
41
+ this.helperPath = path.join(this.hooksDir, 'forward.cjs');
42
+ this.helperOwnershipPath = path.join(this.hooksDir, 'helper-installation.json');
43
+ this.helperJournal = new HelperJournal(this.helperPath, this.helperOwnershipPath);
44
+ this.endpointPath = path.join(this.hooksDir, 'endpoint.json');
45
+ this.nodeExecutable = options.nodeExecutable ?? process.execPath;
46
+ this.homes = {
47
+ codex: path.resolve(base, (options.providerHomes?.codex ?? process.env.CODEX_HOME) || path.join(home, '.codex')),
48
+ claude: path.resolve(base, (options.providerHomes?.claude ?? process.env.CLAUDE_CONFIG_DIR) || path.join(home, '.claude'))
49
+ };
50
+ }
51
+ /**
52
+ * Resolve symlinks once per operation. The receiver authenticates the helper
53
+ * by comparing its argv with realpath(stateDir)/hooks, so commands must use the
54
+ * resolved path. Provider homes may be user-owned symlinks (dotfiles); all I/O
55
+ * then targets the resolved path, which has no symlink components left.
56
+ */
57
+ async prepare() {
58
+ const state = await resolveChecked(path.dirname(this.lexicalHooksDir));
59
+ const hooksDir = path.join(state, 'hooks');
60
+ if (hooksDir !== this.hooksDir) {
61
+ this.hooksDir = hooksDir;
62
+ this.helperPath = path.join(hooksDir, 'forward.cjs');
63
+ this.helperOwnershipPath = path.join(hooksDir, 'helper-installation.json');
64
+ this.helperJournal = new HelperJournal(this.helperPath, this.helperOwnershipPath);
65
+ this.endpointPath = path.join(hooksDir, 'endpoint.json');
66
+ }
67
+ const resolved = {};
68
+ // Failures are per provider: an unsafe Codex home must not block Claude.
69
+ for (const provider of ['codex', 'claude']) {
70
+ const display = this.displayPaths(provider);
71
+ try {
72
+ resolved[provider] = { config: await resolveChecked(display.config),
73
+ notify: display.notify === null ? null : await resolveChecked(display.notify) };
74
+ }
75
+ catch (error) {
76
+ resolved[provider] = { error };
77
+ }
78
+ }
79
+ this.resolved = resolved;
80
+ }
81
+ async status(provider) {
82
+ const display = this.result(provider);
83
+ if (this.options.previewMode)
84
+ return { ...display, reason: 'preview_mode' };
85
+ try {
86
+ await this.prepare();
87
+ const context = this.context(provider);
88
+ const result = this.result(provider);
89
+ const warnings = [];
90
+ const config = jsonConfig(await snapshot(context.configPath));
91
+ const manifest = await this.manifest(context);
92
+ const toml = context.notifyPath ? scanToml((await snapshot(context.notifyPath))?.text ?? '') : [];
93
+ let disabled = config.disableAllHooks === true || config.allowManagedHooksOnly === true;
94
+ if (provider === 'codex')
95
+ disabled = await this.codexPolicy(toml, warnings) || disabled;
96
+ else
97
+ disabled = await this.claudePolicy(warnings) || disabled;
98
+ const ownedNotify = manifest ? this.ownedNotify(manifest) : [];
99
+ const rootNotify = toml.filter((entry) => entry.statement && entry.key[0] === 'notify');
100
+ const oursPresent = rootNotify.some((entry) => this.isOwnedNotify(entry, ownedNotify));
101
+ if (rootNotify.length && !oursPresent)
102
+ warnings.push('notify_foreign');
103
+ if (oursPresent && toml.some((entry) => entry.key[0] === 'profiles' && entry.key[2] === 'notify'))
104
+ warnings.push('notify_profile_override');
105
+ const finish = (value) => warnings.length ? { ...value, warnings } : value;
106
+ if (!manifest)
107
+ return finish(disabled ? { ...result, reason: 'hooks_disabled' } : result);
108
+ if (manifest.phase !== 'installed')
109
+ return finish({ ...result, reason: 'installation_incomplete' });
110
+ if (!hasEntries(config, manifest.entries))
111
+ return finish({ ...result, reason: 'ownership_conflict' });
112
+ // An edited copy of NTerminal's legacy notify is never adopted or ignored.
113
+ if (ownedNotify.length && !oursPresent && rootNotify.some((entry) => this.referencesHelper(entry))) {
114
+ return finish({ ...result, reason: 'ownership_conflict' });
115
+ }
116
+ let current = false;
117
+ try {
118
+ current = equal(manifest.entries, this.entries(provider, manifest.nodeExecutable));
119
+ }
120
+ catch { /* legacy-only path */ }
121
+ if (!current)
122
+ warnings.push('legacy_definitions');
123
+ if (!await snapshot(this.helperPath))
124
+ return finish({ ...result, reason: 'helper_missing' });
125
+ try {
126
+ await access(manifest.nodeExecutable, constants.X_OK);
127
+ }
128
+ catch {
129
+ return finish({ ...result, reason: 'node_unavailable' });
130
+ }
131
+ return finish({ ...result, installed: true, ...(disabled ? { reason: 'hooks_disabled' }
132
+ : provider === 'codex' ? { reason: 'approval_unknown' } : {}) });
133
+ }
134
+ catch (error) {
135
+ return { ...display, ...failure(error) };
136
+ }
137
+ }
138
+ async install(provider) {
139
+ return this.change(provider, false);
140
+ }
141
+ async remove(provider) {
142
+ return this.change(provider, true);
143
+ }
144
+ async refreshManagedHelper() {
145
+ if (this.options.previewMode)
146
+ return { updated: false, reason: 'preview_mode' };
147
+ let release;
148
+ try {
149
+ await this.prepare();
150
+ const manifestPath = (provider) => path.join(this.hooksDir, `${provider}-installation.json`);
151
+ let optedIn = !!await snapshot(this.helperOwnershipPath);
152
+ for (const provider of ['codex', 'claude'])
153
+ if (await snapshot(manifestPath(provider)))
154
+ optedIn = true;
155
+ if (!optedIn)
156
+ return { updated: false, reason: 'not_installed' };
157
+ release = (await acquireVerifiedLock(path.join(this.hooksDir, '.installation.lock'), this.options.procRoot, this.options.machineIdPath)).release;
158
+ const state = await this.helperJournal.resolve();
159
+ optedIn = false;
160
+ for (const provider of ['codex', 'claude']) {
161
+ // A provider without a manifest is irrelevant even if its home is unusable.
162
+ if (!await snapshot(manifestPath(provider)))
163
+ continue;
164
+ const context = this.context(provider);
165
+ const manifest = await this.manifest(context);
166
+ if (!manifest)
167
+ continue;
168
+ const status = await this.status(context.provider);
169
+ if (!status.installed)
170
+ return { updated: false, reason: status.reason ?? 'ownership_conflict' };
171
+ optedIn = true;
172
+ }
173
+ if (!optedIn)
174
+ return { updated: false, reason: 'not_installed' };
175
+ if (!state.owned)
176
+ return { updated: false, reason: 'helper_ownership_unknown' };
177
+ if (!state.helper)
178
+ return { updated: false, reason: 'helper_missing' };
179
+ const source = await snapshot(this.options.helperSource ?? await packagedHelper(), false);
180
+ if (!source)
181
+ return { updated: false, reason: 'helper_unavailable' };
182
+ if (state.helper.text === source.text)
183
+ return { updated: false };
184
+ // No native config rewrite: command bytes and native trust hashes stay stable.
185
+ await this.helperJournal.transition(state, source.text);
186
+ return { updated: true };
187
+ }
188
+ catch (error) {
189
+ return { updated: false, reason: errorCode(error) };
190
+ }
191
+ finally {
192
+ if (release)
193
+ await release();
194
+ }
195
+ }
196
+ async helperReferenced() {
197
+ try {
198
+ for (const provider of ['codex', 'claude']) {
199
+ const context = this.context(provider);
200
+ const config = jsonConfig(await snapshot(context.configPath));
201
+ if (JSON.stringify(config.hooks ?? {}).includes('forward.cjs'))
202
+ return true;
203
+ if (context.notifyPath && (await snapshot(context.notifyPath))?.text.includes('forward.cjs'))
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+ catch {
209
+ return true;
210
+ }
211
+ }
212
+ displayPaths(provider) {
213
+ if (provider !== 'codex' && provider !== 'claude')
214
+ throw new InstallationError('invalid_provider');
215
+ return { config: path.join(this.homes[provider], provider === 'codex' ? 'hooks.json' : 'settings.json'),
216
+ notify: provider === 'codex' ? path.join(this.homes.codex, 'config.toml') : null };
217
+ }
218
+ context(provider) {
219
+ const display = this.displayPaths(provider);
220
+ const resolved = this.resolved[provider] ?? display;
221
+ if ('error' in resolved)
222
+ throw resolved.error;
223
+ return {
224
+ provider, displayConfigPath: display.config, configPath: resolved.config,
225
+ notifyPath: resolved.notify,
226
+ manifestPath: path.join(this.hooksDir, `${provider}-installation.json`)
227
+ };
228
+ }
229
+ result(provider) {
230
+ const display = this.displayPaths(provider);
231
+ return { provider, installed: false, configPath: display.config,
232
+ ...(display.notify ? { notifyConfigPath: display.notify } : {}),
233
+ approvalRequired: provider === 'codex' ? null : false };
234
+ }
235
+ /** Current definitions: guarded POSIX command, async where the response is unused. */
236
+ entries(provider, node = this.nodeExecutable) {
237
+ const argv = this.argv(provider, node).map((value) => shellQuote(value));
238
+ // Codex runs hooks through the user's shell with -c and Claude through bash/$SHELL.
239
+ // Use only POSIX test/exec/exit syntax that sh, bash, zsh, and fish all accept.
240
+ // `exec` keeps the helper's argv[1] for the receiver's process proof. A moved
241
+ // node or a deleted state directory exits 0 silently instead of a hook error.
242
+ const command = `[ -x ${argv[0]} ] && [ -f ${argv[1]} ] && exec ${argv.join(' ')} || exit 0`;
243
+ return (provider === 'claude' ? CLAUDE_EVENTS : CODEX_EVENTS).map((event) => ({
244
+ event, group: {
245
+ ...(event === 'Notification' ? { matcher: '^(permission_prompt|idle_prompt|elicitation_dialog)$' } : {}),
246
+ hooks: [{ type: 'command', command, timeout: 1, ...(ASYNC_EVENTS.has(event) ? { async: true } : {}) }]
247
+ }
248
+ }));
249
+ }
250
+ argv(provider, node = this.nodeExecutable, hooksDir = this.hooksDir) {
251
+ if (!path.isAbsolute(node) || /[\x00-\x1f\x7f]/.test(node))
252
+ throw new InstallationError('node_unavailable');
253
+ return [node, path.join(hooksDir, 'forward.cjs'), '--provider', provider, '--endpoint', path.join(hooksDir, 'endpoint.json')];
254
+ }
255
+ /** Every definition shape an earlier NTerminal version could have written for this node. */
256
+ generations(provider, node) {
257
+ const result = [];
258
+ try {
259
+ result.push({ entries: this.entries(provider, node), notifyBlock: null });
260
+ }
261
+ catch { /* legacy shapes only */ }
262
+ for (const hooksDir of new Set([this.hooksDir, this.lexicalHooksDir])) {
263
+ const argv = this.argv(provider, node, hooksDir);
264
+ const command = argv.map((value) => shellQuote(value, false)).join(' ');
265
+ const entries = LEGACY_EVENTS[provider].map((event) => ({ event, group: {
266
+ ...(event === 'Notification' ? { matcher: '^(permission_prompt|idle_prompt|elicitation_dialog)$' } : {}),
267
+ hooks: [{ type: 'command', command, timeout: 1 }]
268
+ } }));
269
+ const notifyArgv = [...this.argv('codex', node, hooksDir), '--notify'];
270
+ const notifyBlock = provider === 'codex'
271
+ ? `${NOTIFY_BEGIN}\nnotify = [${notifyArgv.map((value) => JSON.stringify(value)).join(', ')}]\n${NOTIFY_END}\n` : null;
272
+ result.push({ entries, notifyBlock });
273
+ result.push({ entries: entries.filter((entry) => !['Interrupt', 'SessionEnd'].includes(entry.event)), notifyBlock });
274
+ }
275
+ return result;
276
+ }
277
+ /** Exact argv arrays of NTerminal's own legacy notify commands recorded in this manifest. */
278
+ ownedNotify(manifest) {
279
+ return [manifest, ...(manifest.prior ?? [])].flatMap((generation) => {
280
+ if (!generation.notifyBlock)
281
+ return [];
282
+ return [JSON.parse(generation.notifyBlock.split('\n')[1].slice('notify = '.length))];
283
+ });
284
+ }
285
+ /** Root `notify = [...]` whose decoded argv exactly equals an owned legacy command, however Codex reformatted it. */
286
+ isOwnedNotify(entry, owned) {
287
+ return entry.statement && !entry.header && entry.key.length === 1 && owned.some((argv) => equal(entry.strings, argv));
288
+ }
289
+ referencesHelper(entry) {
290
+ const helpers = [this.helperPath, path.join(this.lexicalHooksDir, 'forward.cjs')];
291
+ return entry.strings ? entry.strings.some((value) => helpers.includes(value)) : helpers.some((helper) => entry.value.includes(helper));
292
+ }
293
+ async manifest(context, saved) {
294
+ const source = saved === undefined ? await snapshot(context.manifestPath) : saved;
295
+ if (!source)
296
+ return null;
297
+ let value;
298
+ try {
299
+ value = JSON.parse(source.text);
300
+ }
301
+ catch {
302
+ throw new InstallationError('manifest_malformed');
303
+ }
304
+ const known = new Set([...LEGACY_EVENTS[context.provider], ...(context.provider === 'claude' ? CLAUDE_EVENTS : CODEX_EVENTS)]);
305
+ if (!record(value) || value.version !== 1 || value.provider !== context.provider
306
+ || (value.format !== undefined && value.format !== 2)
307
+ || value.configPath !== context.displayConfigPath || typeof value.nodeExecutable !== 'string'
308
+ || !['installing', 'installed', 'removing'].includes(String(value.phase)) || typeof value.createdHooks !== 'boolean'
309
+ || (value.createdConfig !== undefined && typeof value.createdConfig !== 'boolean')
310
+ || !Array.isArray(value.createdEvents) || value.createdEvents.some((event) => typeof event !== 'string' || !known.has(event))) {
311
+ throw new InstallationError('manifest_malformed');
312
+ }
313
+ const validGeneration = (generation) => {
314
+ if (!record(generation) || typeof generation.nodeExecutable !== 'string')
315
+ return false;
316
+ try {
317
+ return this.generations(context.provider, generation.nodeExecutable)
318
+ .some((shape) => equal(generation.entries, shape.entries) && generation.notifyBlock === shape.notifyBlock);
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ };
324
+ if (!validGeneration(value) || (value.helperSha256 !== undefined
325
+ && (typeof value.helperSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.helperSha256)))
326
+ || (value.prior !== undefined && (value.phase === 'installed' || !Array.isArray(value.prior)
327
+ || value.prior.length > 64 || value.prior.some((generation) => !validGeneration(generation))))) {
328
+ throw new InstallationError('manifest_malformed');
329
+ }
330
+ return value;
331
+ }
332
+ /** Cheap, read-only policy checks. Unknown managed state is a warning, never an install blocker. */
333
+ async codexPolicy(toml, warnings) {
334
+ const feature = (entries, prefix) => {
335
+ const find = (name) => entries.find((entry) => equal(entry.key, [...prefix, 'features', name]))?.value;
336
+ // The canonical key overrides the deprecated alias in Codex 0.157.1.
337
+ // Missing feature keys retain the native default (enabled); never opt in here.
338
+ return (find('hooks') ?? find('codex_hooks')) === 'false';
339
+ };
340
+ const managedOnly = (entries) => entries.some((entry) => equal(entry.key, ['allow_managed_hooks_only']) && entry.value === 'true');
341
+ let disabled = feature(toml, []) || managedOnly(toml);
342
+ const active = toml.find((entry) => entry.statement && equal(entry.key, ['profile']))?.text;
343
+ const profiles = new Set(toml.filter((entry) => entry.key[0] === 'profiles' && entry.key.length > 1).map((entry) => entry.key[1]));
344
+ for (const profile of profiles) {
345
+ if (!feature(toml, ['profiles', profile]))
346
+ continue;
347
+ if (profile === active)
348
+ disabled = true;
349
+ else if (!warnings.includes('profile_hooks_disabled'))
350
+ warnings.push('profile_hooks_disabled');
351
+ }
352
+ const system = this.options.codexSystemDir ?? '/etc/codex';
353
+ for (const name of ['requirements.toml', 'managed_config.toml']) {
354
+ try {
355
+ const saved = await snapshot(path.join(system, name), false);
356
+ if (!saved)
357
+ continue;
358
+ const managed = scanToml(saved.text);
359
+ if (managedOnly(managed) || feature(managed, []))
360
+ disabled = true;
361
+ }
362
+ catch {
363
+ warnings.push('managed_policy_unreadable');
364
+ }
365
+ }
366
+ return disabled;
367
+ }
368
+ async claudePolicy(warnings) {
369
+ const directory = this.options.claudeManagedDir
370
+ ?? (process.platform === 'darwin' ? '/Library/Application Support/ClaudeCode' : '/etc/claude-code');
371
+ const files = [path.join(directory, 'managed-settings.json')];
372
+ try {
373
+ const dropIns = (await readdir(path.join(directory, 'managed-settings.d'))).filter((name) => name.endsWith('.json')).sort();
374
+ files.push(...dropIns.slice(0, 32).map((name) => path.join(directory, 'managed-settings.d', name)));
375
+ }
376
+ catch (error) {
377
+ if (!missing(error) && error.code !== 'ENOTDIR')
378
+ warnings.push('managed_policy_unreadable');
379
+ }
380
+ let disabled = false;
381
+ for (const file of files) {
382
+ try {
383
+ const saved = await snapshot(file, false);
384
+ if (!saved)
385
+ continue;
386
+ const value = JSON.parse(saved.text);
387
+ if (record(value) && (value.disableAllHooks === true || value.allowManagedHooksOnly === true))
388
+ disabled = true;
389
+ }
390
+ catch {
391
+ if (!warnings.includes('managed_policy_unreadable'))
392
+ warnings.push('managed_policy_unreadable');
393
+ }
394
+ }
395
+ return disabled;
396
+ }
397
+ async change(provider, remove) {
398
+ const display = this.result(provider);
399
+ if (this.options.previewMode)
400
+ return { ...display, reason: 'preview_mode' };
401
+ const releases = [];
402
+ try {
403
+ await this.prepare();
404
+ const context = this.context(provider);
405
+ if (remove && !await snapshot(context.manifestPath))
406
+ return this.status(provider);
407
+ await ensureDirectory(this.hooksDir);
408
+ releases.push((await acquireVerifiedLock(path.join(this.hooksDir, '.installation.lock'), this.options.procRoot, this.options.machineIdPath)).release);
409
+ const helperBefore = await this.helperJournal.resolve();
410
+ await ensureDirectory(path.dirname(context.configPath));
411
+ releases.push((await acquireVerifiedLock(path.join(path.dirname(context.configPath), '.nterminal-hooks.lock'), this.options.procRoot, this.options.machineIdPath)).release);
412
+ const originalManifest = await snapshot(context.manifestPath);
413
+ const previous = await this.manifest(context, originalManifest);
414
+ if (remove && !previous)
415
+ return this.status(provider);
416
+ const originalConfig = await snapshot(context.configPath);
417
+ const config = jsonConfig(originalConfig);
418
+ const originalNotify = context.notifyPath ? await snapshot(context.notifyPath) : null;
419
+ const toml = originalNotify?.text ?? '';
420
+ const tomlEntries = context.notifyPath ? scanToml(toml) : [];
421
+ const createdHooks = previous?.createdHooks ?? !Object.hasOwn(config, 'hooks');
422
+ const createdConfig = previous ? previous.createdConfig === true : originalConfig === null;
423
+ let next = null;
424
+ let helperText;
425
+ const entries = remove ? null : this.entries(provider);
426
+ // Cumulative: also keeps event keys NTerminal created for since-dropped events
427
+ // (e.g. Subagent*) deletable across an interrupted upgrade. Only empty keys are deleted.
428
+ const createdEvents = remove ? previous.createdEvents : [...new Set([...(previous?.createdEvents ?? []), ...entries.map((entry) => entry.event)
429
+ .filter((event) => !record(config.hooks) || !Object.hasOwn(config.hooks, event))])];
430
+ const owned = previous ? [previous, ...(previous.prior ?? [])].map((generation) => generation.entries) : [];
431
+ // Update NTerminal's groups in place: their index is part of Codex's trust key,
432
+ // and moving them would also shift every later user group into re-review.
433
+ const removedGroups = applyEntries(config, owned, previous?.phase === 'installed' ? previous.entries : null, entries, createdEvents);
434
+ let notify = toml;
435
+ if (previous) {
436
+ const ownedArgv = this.ownedNotify(previous);
437
+ const rootNotify = tomlEntries.filter((entry) => entry.statement && entry.key[0] === 'notify');
438
+ const ours = rootNotify.filter((entry) => this.isOwnedNotify(entry, ownedArgv));
439
+ if (ours.length)
440
+ notify = removeNotify(notify, ours);
441
+ else if (ownedArgv.length && rootNotify.some((entry) => this.referencesHelper(entry)))
442
+ throw new InstallationError('ownership_conflict');
443
+ }
444
+ if (removedGroups.length && context.notifyPath) {
445
+ notify = removeTrust(notify, [...new Set([context.displayConfigPath, context.configPath])], removedGroups);
446
+ }
447
+ const changes = [];
448
+ if (!remove) {
449
+ this.argv(provider);
450
+ try {
451
+ await access(this.nodeExecutable, constants.X_OK);
452
+ }
453
+ catch {
454
+ throw new InstallationError('node_unavailable');
455
+ }
456
+ const source = await snapshot(this.options.helperSource ?? await packagedHelper(), false);
457
+ if (!source)
458
+ throw new InstallationError('helper_unavailable');
459
+ if (!helperBefore.owned && helperBefore.helper && helperBefore.helper.text !== source.text)
460
+ throw new InstallationError('helper_conflict');
461
+ helperText = source.text;
462
+ next = { version: 1, format: 2, provider, configPath: context.displayConfigPath, nodeExecutable: this.nodeExecutable,
463
+ phase: 'installed', createdHooks, createdConfig, createdEvents, entries: entries, notifyBlock: null };
464
+ }
465
+ else if (createdHooks && record(config.hooks) && Object.keys(config.hooks).length === 0) {
466
+ delete config.hooks;
467
+ }
468
+ const configText = remove && createdConfig && Object.keys(config).length === 0 ? null : serializeJson(originalConfig?.text ?? null, config);
469
+ changes.push({ target: context.configPath, before: originalConfig, text: configText });
470
+ if (context.notifyPath && notify !== toml)
471
+ changes.push({ target: context.notifyPath, before: originalNotify, text: notify });
472
+ // Persist exact ownership before any provider changes, allowing recovery
473
+ // after interruption without adopting similarly named user hooks.
474
+ const journal = { ...(next ?? previous), phase: next ? 'installing' : 'removing' };
475
+ if (next && previous) {
476
+ // Retain exact old definitions until the new configuration is committed.
477
+ // A process can exit between writing the manifest and either config file.
478
+ journal.prior = [previous, ...(previous.prior ?? [])]
479
+ .map(({ nodeExecutable, entries, notifyBlock }) => ({ nodeExecutable, entries, notifyBlock }))
480
+ .filter((generation, index, all) => all.findIndex((other) => equal(other, generation)) === index);
481
+ if (journal.prior.length > 64)
482
+ throw new InstallationError('installation_incomplete');
483
+ }
484
+ let manifestSnapshot = await replace(context.manifestPath, originalManifest, JSON.stringify(journal, null, 2) + '\n', { durable: true });
485
+ const written = [];
486
+ try {
487
+ if (helperText !== undefined)
488
+ await this.helperJournal.transition(helperBefore, helperText);
489
+ for (const change of changes) {
490
+ await replace(change.target, change.before, change.text, { durable: true,
491
+ onApplied: (after) => written.push({ ...change, after }) });
492
+ }
493
+ manifestSnapshot = await replace(context.manifestPath, manifestSnapshot, next ? JSON.stringify(next, null, 2) + '\n' : null, { durable: true, onApplied: (after) => { manifestSnapshot = after; } });
494
+ }
495
+ catch (error) {
496
+ // The final manifest rename/unlink may have succeeded before its fsync
497
+ // failed. Re-establish durable incomplete ownership before compensation;
498
+ // otherwise an exit during rollback leaves installed ownership for
499
+ // partially restored files. A failed journal barrier forbids rollback.
500
+ manifestSnapshot = await replace(context.manifestPath, manifestSnapshot, JSON.stringify(journal, null, 2) + '\n', { durable: true });
501
+ let restored = true;
502
+ for (const change of written.reverse()) {
503
+ try {
504
+ await replace(change.target, change.after, change.before?.text ?? null, { durable: true });
505
+ }
506
+ catch {
507
+ restored = false;
508
+ }
509
+ }
510
+ // Native config rollback comes first. A failed CAS may leave a hook
511
+ // referencing the committed helper, so never remove it in that case.
512
+ if (restored && helperText !== undefined) {
513
+ try {
514
+ if (!helperBefore.helper && await this.helperReferenced())
515
+ restored = false;
516
+ else
517
+ await this.helperJournal.transition(await this.helperJournal.resolve(), helperBefore.helper?.text ?? null);
518
+ }
519
+ catch {
520
+ restored = false;
521
+ }
522
+ }
523
+ if (restored)
524
+ await replace(context.manifestPath, manifestSnapshot, originalManifest?.text ?? null, { durable: true });
525
+ throw error;
526
+ }
527
+ return this.status(provider);
528
+ }
529
+ catch (error) {
530
+ return { ...await this.status(provider), ...failure(error) };
531
+ }
532
+ finally {
533
+ for (const release of releases.reverse())
534
+ await release();
535
+ }
536
+ }
537
+ }
538
+ function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
539
+ function canonical(value) {
540
+ return JSON.stringify(value, (_key, entry) => record(entry)
541
+ ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) : entry) ?? 'undefined';
542
+ }
543
+ function equal(a, b) { return canonical(a) === canonical(b); }
544
+ function jsonConfig(saved) {
545
+ if (!saved)
546
+ return {};
547
+ let value;
548
+ try {
549
+ value = JSON.parse(saved.text);
550
+ }
551
+ catch {
552
+ throw new InstallationError('config_malformed');
553
+ }
554
+ if (!record(value) || (Object.hasOwn(value, 'hooks') && !record(value.hooks)))
555
+ throw new InstallationError('config_malformed');
556
+ if (record(value.hooks) && Object.values(value.hooks).some((groups) => !Array.isArray(groups)
557
+ || groups.some((group) => !record(group) || !Array.isArray(group.hooks) || group.hooks.some((hook) => !record(hook))))) {
558
+ throw new InstallationError('config_malformed');
559
+ }
560
+ return value;
561
+ }
562
+ /**
563
+ * Keep the user's formatting where practical: unchanged content keeps its exact
564
+ * bytes; otherwise preserve indentation, line endings, the trailing newline, and
565
+ * key order (JSON.parse/stringify retain insertion order).
566
+ */
567
+ function serializeJson(original, value) {
568
+ if (original !== null) {
569
+ try {
570
+ if (equal(JSON.parse(original), value))
571
+ return original;
572
+ }
573
+ catch { /* rewritten below */ }
574
+ }
575
+ const indent = original === null ? ' ' : /\n([ \t]+)\S/.exec(original)?.[1] ?? (original.trim().includes('\n') ? ' ' : '');
576
+ let text = JSON.stringify(value, null, indent);
577
+ if (original === null || /\n\s*$/.test(original))
578
+ text += '\n';
579
+ if (original?.includes('\r\n'))
580
+ text = text.replaceAll('\n', '\r\n');
581
+ return text;
582
+ }
583
+ function hasEntries(config, entries) {
584
+ if (!record(config.hooks))
585
+ return false;
586
+ const hooks = config.hooks;
587
+ return entries.every((entry) => Array.isArray(hooks[entry.event])
588
+ && hooks[entry.event].filter((group) => equal(group, entry.group)).length === 1);
589
+ }
590
+ function commands(group) {
591
+ return record(group) && Array.isArray(group.hooks) ? group.hooks.map((hook) => hook.command) : [];
592
+ }
593
+ /**
594
+ * Replace owned groups in place (or remove them when `next` lacks the event),
595
+ * append genuinely new events, and return the original index of every owned
596
+ * group that was removed. Unknown groups carrying an owned command fail closed.
597
+ */
598
+ function applyEntries(config, owned, current, next, createdEvents) {
599
+ const removed = [];
600
+ if (!record(config.hooks)) {
601
+ if (current?.length)
602
+ throw new InstallationError('ownership_conflict');
603
+ if (!next)
604
+ return removed;
605
+ }
606
+ const hooks = record(config.hooks) ? config.hooks : {};
607
+ const events = [...new Set([...owned.flat().map((entry) => entry.event), ...(next ?? []).map((entry) => entry.event)])];
608
+ for (const event of events) {
609
+ const existing = Object.hasOwn(hooks, event);
610
+ const groups = (existing ? hooks[event] : []);
611
+ const ownedGroups = owned.flatMap((generation) => generation.filter((entry) => entry.event === event).map((entry) => entry.group));
612
+ const ownedCommands = new Set(ownedGroups.flatMap(commands));
613
+ const currentGroup = current?.find((entry) => entry.event === event)?.group;
614
+ if (currentGroup && groups.filter((group) => equal(group, currentGroup)).length !== 1)
615
+ throw new InstallationError('ownership_conflict');
616
+ if (ownedGroups.some((owned) => groups.filter((group) => equal(group, owned)).length > 1))
617
+ throw new InstallationError('ownership_conflict');
618
+ const isOwned = groups.map((group) => ownedGroups.some((owned) => equal(group, owned)));
619
+ if (groups.some((group, index) => !isOwned[index] && commands(group).some((command) => ownedCommands.has(command)))) {
620
+ throw new InstallationError('ownership_conflict');
621
+ }
622
+ const nextGroup = next?.find((entry) => entry.event === event)?.group;
623
+ const nextCommands = new Set(commands(nextGroup));
624
+ if (groups.some((group, index) => !isOwned[index] && commands(group).some((command) => nextCommands.has(command)))) {
625
+ throw new InstallationError('ownership_conflict');
626
+ }
627
+ const result = [];
628
+ let placed = false;
629
+ groups.forEach((group, index) => {
630
+ if (!isOwned[index])
631
+ result.push(group);
632
+ else if (nextGroup && !placed) {
633
+ result.push(nextGroup);
634
+ placed = true;
635
+ }
636
+ else
637
+ removed.push({ event, index });
638
+ });
639
+ if (nextGroup && !placed)
640
+ result.push(nextGroup);
641
+ if (result.length || (existing && !createdEvents.includes(event)))
642
+ hooks[event] = result;
643
+ else
644
+ delete hooks[event];
645
+ }
646
+ if (next || record(config.hooks))
647
+ config.hooks = hooks;
648
+ return removed;
649
+ }
650
+ function shellQuote(value, strict = true) {
651
+ // Backslashes are literal in POSIX single quotes but not in fish; refuse them.
652
+ if (/[\x00-\x1f\x7f]/.test(value) || (strict && value.includes('\\')))
653
+ throw new InstallationError('unsafe_path', value);
654
+ return `'${value.replaceAll("'", "'\\''")}'`;
655
+ }
656
+ function failure(error) {
657
+ const reason = errorCode(error);
658
+ return reason === 'unsafe_path' && error instanceof InstallationError && error.path ? { reason, unsafePath: error.path } : { reason };
659
+ }
660
+ function errorCode(error) {
661
+ if (error instanceof InstallationError)
662
+ return error.code;
663
+ return error?.code === 'ELOOP' ? 'unsafe_path' : 'installation_unavailable';
664
+ }
665
+ async function packagedHelper() {
666
+ let directory = path.dirname(fileURLToPath(import.meta.url));
667
+ while (true) {
668
+ try {
669
+ const metadata = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'));
670
+ if (record(metadata) && metadata.name === 'nterminal')
671
+ return path.join(directory, 'scripts', 'nterminal-hook.cjs');
672
+ }
673
+ catch (error) {
674
+ if (!missing(error))
675
+ throw error;
676
+ }
677
+ const parent = path.dirname(directory);
678
+ if (parent === directory)
679
+ throw new InstallationError('helper_unavailable');
680
+ directory = parent;
681
+ }
682
+ }
683
+ /** realpath of the deepest existing ancestor plus the missing remainder. */
684
+ async function resolveExisting(target) {
685
+ try {
686
+ return await realpath(target);
687
+ }
688
+ catch (error) {
689
+ const code = error.code;
690
+ if (code === 'ELOOP')
691
+ throw new InstallationError('unsafe_path', target);
692
+ if (!missing(error) && code !== 'ENOTDIR')
693
+ throw error;
694
+ const parent = path.dirname(target);
695
+ return parent === target ? target : path.join(await resolveExisting(parent), path.basename(target));
696
+ }
697
+ }
698
+ /**
699
+ * Accept a symlinked path component only when the resolved directory NTerminal
700
+ * writes into belongs to this user (or, when not yet created, sits in a
701
+ * directory owned by this user or root) and is not writable by others.
702
+ */
703
+ async function resolveChecked(target) {
704
+ const resolved = await resolveExisting(target);
705
+ if (resolved === target)
706
+ return resolved;
707
+ let directory = path.dirname(resolved);
708
+ const leaf = await lstat(resolved).catch((error) => { if (missing(error))
709
+ return null; throw error; });
710
+ if (leaf?.isDirectory())
711
+ directory = resolved;
712
+ for (let first = true;; first = false) {
713
+ const info = await lstat(directory).catch((error) => { if (missing(error))
714
+ return null; throw error; });
715
+ if (info) {
716
+ const uid = process.getuid?.();
717
+ const ownerOk = uid === undefined || info.uid === uid || (!first && info.uid === 0);
718
+ if (!info.isDirectory() || !ownerOk || (info.mode & 0o002) !== 0)
719
+ throw new InstallationError('unsafe_path', directory);
720
+ return resolved;
721
+ }
722
+ const parent = path.dirname(directory);
723
+ if (parent === directory)
724
+ return resolved;
725
+ directory = parent;
726
+ }
727
+ }
728
+ function snakeCase(event) { return event.replace(/(?<!^)[A-Z]/g, (letter) => `_${letter}`).toLowerCase(); }
729
+ function removeRanges(source, ranges) {
730
+ let result = source;
731
+ for (const [start, end] of [...ranges].sort((a, b) => b[0] - a[0]))
732
+ result = result.slice(0, start) + result.slice(end);
733
+ return result;
734
+ }
735
+ /** Remove NTerminal's legacy notify statement and its marker comments; nothing else. */
736
+ function removeNotify(source, statements) {
737
+ const ranges = statements.map((entry) => [entry.start, entry.end]);
738
+ for (const marker of [NOTIFY_BEGIN, NOTIFY_END]) {
739
+ const match = new RegExp(`(^|\\n)(${marker.replaceAll(' ', '[ ]')}[ \\t]*(?:\\r?\\n|$))`).exec(source);
740
+ if (match)
741
+ ranges.push([match.index + match[1].length, match.index + match[0].length]);
742
+ }
743
+ return removeRanges(source, ranges);
744
+ }
745
+ /**
746
+ * Remove Codex trust records keyed to NTerminal groups that were just removed
747
+ * (`[hooks.state."<hooks.json>:<event>:<group>:0"]`). Other bytes are unchanged.
748
+ */
749
+ function removeTrust(source, configPaths, removed) {
750
+ const keys = new Set(removed.flatMap(({ event, index }) => configPaths.flatMap((file) => [...new Set([event, snakeCase(event)])].map((name) => `${file}:${name}:${index}:0`))));
751
+ const entries = scanToml(source);
752
+ const ranges = [];
753
+ entries.forEach((entry, index) => {
754
+ if (!entry.statement || entry.key.length < 3 || entry.key[0] !== 'hooks' || entry.key[1] !== 'state' || !keys.has(entry.key[2]))
755
+ return;
756
+ if (!entry.header) {
757
+ // Dotted (`hooks.state."K".trusted_hash`) or inline-table (`"K" = {...}`) records.
758
+ ranges.push([entry.start, entry.end]);
759
+ return;
760
+ }
761
+ if (entry.key.length !== 3)
762
+ return;
763
+ let end = entry.end;
764
+ for (const member of entries.slice(index + 1)) {
765
+ if (member.header)
766
+ break;
767
+ if (member.statement)
768
+ end = member.end;
769
+ }
770
+ let start = entry.start;
771
+ // Drop the blank separator line that preceded the removed table.
772
+ if (source.slice(0, start).endsWith('\n\n'))
773
+ start -= 1;
774
+ else if (source.slice(0, start).endsWith('\r\n\r\n'))
775
+ start -= 2;
776
+ ranges.push([start, end]);
777
+ });
778
+ return ranges.length ? removeRanges(source, ranges) : source;
779
+ }
780
+ // A lossless, narrow TOML editor: only inspect statement boundaries and keys;
781
+ // never serialize user tables/arrays. Unrecognized value syntax fails closed.
782
+ function scanToml(source) {
783
+ if (source.includes('') || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(source))
784
+ throw new InstallationError('config_malformed');
785
+ let position = 0;
786
+ let table = [];
787
+ const result = [];
788
+ const rootKeys = new Set();
789
+ const fail = () => { throw new InstallationError('config_malformed'); };
790
+ const space = () => { while (source[position] === ' ' || source[position] === '\t')
791
+ position++; };
792
+ const trivia = () => {
793
+ while (position < source.length) {
794
+ if (/\s/.test(source[position])) {
795
+ position++;
796
+ continue;
797
+ }
798
+ if (source[position] === '#') {
799
+ while (position < source.length && source[position] !== '\n')
800
+ position++;
801
+ continue;
802
+ }
803
+ break;
804
+ }
805
+ };
806
+ const string = (key = false) => {
807
+ const quote = source[position++];
808
+ const triple = !key && source.slice(position, position + 2) === quote.repeat(2);
809
+ if (triple)
810
+ position += 2;
811
+ let decoded = '';
812
+ while (position < source.length) {
813
+ const char = source[position++];
814
+ if (char === quote) {
815
+ if (!triple)
816
+ return decoded;
817
+ if (source.slice(position, position + 2) === quote.repeat(2)) {
818
+ position += 2;
819
+ for (let extra = 0; extra < 2 && source[position] === quote; extra++) {
820
+ decoded += quote;
821
+ position++;
822
+ }
823
+ return decoded;
824
+ }
825
+ }
826
+ if (!triple && /[\r\n]/.test(char))
827
+ fail();
828
+ if (char < ' ' && char !== '\t' && !(triple && /[\r\n]/.test(char)))
829
+ fail();
830
+ if (char === '\\' && quote === '"') {
831
+ const escaped = source[position++] ?? fail();
832
+ if (triple && /\s/.test(escaped)) {
833
+ while (position < source.length && /\s/.test(source[position]))
834
+ position++;
835
+ continue;
836
+ }
837
+ if ('btnfr"\\'.includes(escaped))
838
+ decoded += { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r' }[escaped] ?? escaped;
839
+ else if (escaped === 'u' || escaped === 'U') {
840
+ const count = escaped === 'u' ? 4 : 8;
841
+ const hex = source.slice(position, position + count);
842
+ if (hex.length !== count || !/^[0-9a-f]+$/i.test(hex))
843
+ fail();
844
+ const code = parseInt(hex, 16);
845
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
846
+ fail();
847
+ decoded += String.fromCodePoint(code);
848
+ position += count;
849
+ }
850
+ else
851
+ fail();
852
+ }
853
+ else
854
+ decoded += char;
855
+ }
856
+ return fail();
857
+ };
858
+ const key = () => {
859
+ const parts = [];
860
+ while (true) {
861
+ space();
862
+ if (source[position] === '"' || source[position] === "'")
863
+ parts.push(string(true));
864
+ else {
865
+ const match = /^[A-Za-z0-9_-]+/.exec(source.slice(position));
866
+ if (!match)
867
+ fail();
868
+ parts.push(match[0]);
869
+ position += match[0].length;
870
+ }
871
+ space();
872
+ if (source[position] !== '.')
873
+ return parts;
874
+ position++;
875
+ }
876
+ };
877
+ // Returns a decoded string, or the decoded elements of an all-string array.
878
+ const value = (depth = 0, prefix) => {
879
+ if (depth > 64)
880
+ fail();
881
+ space();
882
+ const start = source[position];
883
+ if (start === '"' || start === "'")
884
+ return { text: string() };
885
+ if (start === '[' || start === '{') {
886
+ position++;
887
+ const end = start === '[' ? ']' : '}';
888
+ let strings = start === '[' ? [] : undefined;
889
+ trivia();
890
+ while (source[position] !== end) {
891
+ let parts;
892
+ if (start === '{') {
893
+ const keys = key();
894
+ parts = prefix && [...prefix, ...keys];
895
+ if (source[position++] !== '=')
896
+ fail();
897
+ }
898
+ const begin = position;
899
+ const element = value(depth + 1, parts);
900
+ if (strings) {
901
+ if (element.text === undefined)
902
+ strings = undefined;
903
+ else
904
+ strings.push(element.text);
905
+ }
906
+ if (parts)
907
+ result.push({ key: parts, value: source.slice(begin, position).trim(), start: begin, end: position, header: false, statement: false, ...element });
908
+ trivia();
909
+ if (source[position] === end)
910
+ break;
911
+ if (source[position++] !== ',')
912
+ fail();
913
+ trivia();
914
+ }
915
+ position++;
916
+ return strings ? { strings } : {};
917
+ }
918
+ const begin = position;
919
+ while (position < source.length && !/[\r\n,#\]}]/.test(source[position]))
920
+ position++;
921
+ const token = source.slice(begin, position).trim();
922
+ if (!/^(?:true|false|[+-]?(?:inf|nan)|[+-]?(?:0|[1-9](?:_?\d)*)(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(token))
923
+ fail();
924
+ return {};
925
+ };
926
+ const lineEnd = () => {
927
+ space();
928
+ if (source[position] === '#')
929
+ while (position < source.length && source[position] !== '\n')
930
+ position++;
931
+ if (position < source.length && !/[\r\n]/.test(source[position]))
932
+ fail();
933
+ if (source[position] === '\r' && source[position + 1] === '\n')
934
+ position += 2;
935
+ else if (source[position] === '\n')
936
+ position++;
937
+ else if (source[position] === '\r')
938
+ fail();
939
+ return position;
940
+ };
941
+ while (position < source.length) {
942
+ trivia();
943
+ if (position === source.length)
944
+ break;
945
+ const statementStart = position;
946
+ if (source[position] === '[') {
947
+ position++;
948
+ const array = source[position] === '[';
949
+ if (array)
950
+ position++;
951
+ table = key();
952
+ if (source[position++] !== ']' || (array && source[position++] !== ']'))
953
+ fail();
954
+ const entry = { key: table, value: '', start: statementStart, end: 0, header: true, statement: true };
955
+ entry.end = lineEnd();
956
+ result.push(entry);
957
+ }
958
+ else {
959
+ const parts = key();
960
+ if (source[position++] !== '=')
961
+ fail();
962
+ const start = position;
963
+ const element = value(0, [...table, ...parts]);
964
+ if (table.length === 0) {
965
+ const name = JSON.stringify(parts);
966
+ if (rootKeys.has(name))
967
+ fail();
968
+ rootKeys.add(name);
969
+ }
970
+ const entry = { key: [...table, ...parts], value: source.slice(start, position).trim(), start: statementStart, end: 0,
971
+ header: false, statement: true, ...element };
972
+ entry.end = lineEnd();
973
+ result.push(entry);
974
+ }
975
+ }
976
+ return result;
977
+ }
978
+ //# sourceMappingURL=hookInstallation.js.map