@phyzess/pappra 0.1.0-beta.1 → 0.1.0-beta.10
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/README.md +30 -11
- package/assets/integrations/skills/pappra/SKILL.md +7 -4
- package/assets/integrations/skills/pappra/references/hosts.md +21 -11
- package/assets/integrations/skills/pappra/references/lifecycle.md +4 -0
- package/assets/integrations/skills/pappra/references/operations.md +20 -7
- package/dist/app/collaboration.js +6 -13
- package/dist/app/doctor.js +2 -2
- package/dist/app/init.d.ts +28 -0
- package/dist/app/init.js +419 -88
- package/dist/cli/commands/docs.d.ts +1 -0
- package/dist/cli/commands/docs.js +104 -3
- package/dist/cli/commands/host.d.ts +5 -1
- package/dist/cli/commands/host.js +80 -6
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +186 -9
- package/dist/cli/commands/runtime.d.ts +14 -0
- package/dist/cli/commands/runtime.js +263 -0
- package/dist/cli/run.js +159 -7
- package/dist/hosts/detect.d.ts +1 -0
- package/dist/hosts/detect.js +1 -1
- package/dist/hosts/integration/bundle-export.d.ts +24 -0
- package/dist/hosts/integration/bundle-export.js +128 -0
- package/dist/hosts/integration/bundle.d.ts +4 -1
- package/dist/hosts/integration/bundle.js +20 -2
- package/dist/hosts/integration/pappra-extension-runtime.mjs +24610 -0
- package/dist/hosts/integration/pappra-hook.mjs +24728 -0
- package/dist/hosts/integration/service.d.ts +8 -0
- package/dist/hosts/integration/service.js +71 -31
- package/dist/hosts/integration/support.d.ts +23 -0
- package/dist/hosts/integration/support.js +25 -0
- package/dist/hosts/integration/templates.d.ts +3 -3
- package/dist/hosts/integration/templates.js +15 -10
- package/dist/hosts/pi/rpc.d.ts +2 -0
- package/dist/hosts/pi/rpc.js +39 -1
- package/dist/hosts/pilot.d.ts +77 -77
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/install/runtime.d.ts +1 -1
- package/dist/install/runtime.js +117 -10
- package/dist/package-info.d.ts +2 -0
- package/dist/package-info.js +6 -0
- package/dist/schemas/host.js +4 -2
- package/dist/schemas/install.d.ts +14 -0
- package/dist/schemas/install.js +8 -0
- package/package.json +8 -2
- package/skills/pappra/SKILL.md +7 -4
- package/skills/pappra/references/hosts.md +21 -11
- package/skills/pappra/references/lifecycle.md +4 -0
- package/skills/pappra/references/operations.md +20 -7
|
@@ -12,6 +12,14 @@ export interface HostTargetOptions {
|
|
|
12
12
|
readonly hostRoot?: string;
|
|
13
13
|
readonly runtimeEntry?: string;
|
|
14
14
|
readonly hostVersion?: string | null;
|
|
15
|
+
/** Rewrites a manifest previously pinned to a package checkout path. */
|
|
16
|
+
readonly migrate?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Plugin wiring is exported with `host bundle` but not installed here: host
|
|
19
|
+
* registration, trust and callability are unverified, so this mode fails
|
|
20
|
+
* closed instead of writing a bundle a host may never load.
|
|
21
|
+
*/
|
|
22
|
+
readonly mode?: 'project-settings' | 'plugin';
|
|
15
23
|
}
|
|
16
24
|
export interface HostPlanBundle {
|
|
17
25
|
readonly plan: HostIntegrationPlan;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { link, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { createDiagnostic } from '../../schemas/diagnostics.js';
|
|
6
6
|
import { diagnosticCodes } from '../../schemas/errors.js';
|
|
@@ -12,7 +12,7 @@ import { getPackageInfo } from '../../package-info.js';
|
|
|
12
12
|
import { detectPassiveHosts } from '../detect.js';
|
|
13
13
|
import { installPathForBundleFile, loadHostBundle } from './bundle.js';
|
|
14
14
|
import { hostConfigRelativePath, renderHookConfigPatch } from './templates.js';
|
|
15
|
-
import { HOST_SUPPORT, isSupportedVersion, nativeEventFor, surfaceFor, } from './support.js';
|
|
15
|
+
import { HOST_PLUGIN_HOOKS, HOST_SUPPORT, isSupportedVersion, nativeEventFor, surfaceFor, } from './support.js';
|
|
16
16
|
const LIFECYCLE_EVENTS = [
|
|
17
17
|
'startup',
|
|
18
18
|
'resume',
|
|
@@ -26,7 +26,20 @@ function now() {
|
|
|
26
26
|
}
|
|
27
27
|
function packageRuntimeEntry() {
|
|
28
28
|
const packageJson = fileURLToPath(import.meta.resolve('@phyzess/pappra/package.json'));
|
|
29
|
-
return join(dirname(packageJson), 'dist', 'hosts', 'integration', 'hook
|
|
29
|
+
return join(dirname(packageJson), 'dist', 'hosts', 'integration', 'pappra-hook.mjs');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Uses the host's project placeholder when the hook copy lives inside the
|
|
33
|
+
* workspace, so a moved checkout or an evicted npx cache cannot break the hook.
|
|
34
|
+
*/
|
|
35
|
+
function hookTarget(host, workspaceRoot, hostRoot, hookSource) {
|
|
36
|
+
const installedPath = join(hostRoot, 'hooks', 'pappra-hook.mjs');
|
|
37
|
+
const relativePath = relative(workspaceRoot, installedPath);
|
|
38
|
+
const insideWorkspace = !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
39
|
+
const expression = host === 'claude' && insideWorkspace
|
|
40
|
+
? `\${CLAUDE_PROJECT_DIR}/${relativePath.split(sep).join('/')}`
|
|
41
|
+
: installedPath;
|
|
42
|
+
return { installedPath, expression, source: hookSource };
|
|
30
43
|
}
|
|
31
44
|
function diagnostic(code, message, source, hint) {
|
|
32
45
|
return createDiagnostic({
|
|
@@ -211,7 +224,7 @@ function isExactManagedHook(value, commands) {
|
|
|
211
224
|
typeof value.command === 'string' &&
|
|
212
225
|
commands.has(value.command));
|
|
213
226
|
}
|
|
214
|
-
function managedConfig(host, existing,
|
|
227
|
+
function managedConfig(host, existing, hookExpression, path) {
|
|
215
228
|
let base = {};
|
|
216
229
|
if (existing !== null) {
|
|
217
230
|
const parsed = parseJsonObject(existing, path);
|
|
@@ -219,7 +232,7 @@ function managedConfig(host, existing, runtimeEntry, path) {
|
|
|
219
232
|
return parsed;
|
|
220
233
|
base = parsed.value;
|
|
221
234
|
}
|
|
222
|
-
const patch = renderHookConfigPatch(host,
|
|
235
|
+
const patch = renderHookConfigPatch(host, hookExpression);
|
|
223
236
|
const rawHooks = base['hooks'];
|
|
224
237
|
if (rawHooks !== undefined &&
|
|
225
238
|
(typeof rawHooks !== 'object' ||
|
|
@@ -271,11 +284,11 @@ function managedConfig(host, existing, runtimeEntry, path) {
|
|
|
271
284
|
bytes: Buffer.from(`${JSON.stringify(next, null, 2)}\n`),
|
|
272
285
|
};
|
|
273
286
|
}
|
|
274
|
-
function validateManifestIdentity(manifest, host, hostRoot, configPath,
|
|
287
|
+
function validateManifestIdentity(manifest, host, hostRoot, configPath, hookExpression, migrate) {
|
|
275
288
|
if (manifest.host !== host ||
|
|
276
289
|
resolve(manifest.hostRoot) !== hostRoot ||
|
|
277
290
|
manifest.configPath !== configPath ||
|
|
278
|
-
|
|
291
|
+
(!migrate && manifest.runtimeEntry !== hookExpression)) {
|
|
279
292
|
return diagnostic(diagnosticCodes.host_install_conflict, `Host manifest identity does not match the selected target: ${hostRoot}`, join(hostRoot, BUNDLE_FILE), 'Inspect the manifest and target; Pappra does not adopt a foreign installation.');
|
|
280
293
|
}
|
|
281
294
|
const seen = new Set();
|
|
@@ -290,11 +303,11 @@ function validateManifestIdentity(manifest, host, hostRoot, configPath, runtimeE
|
|
|
290
303
|
}
|
|
291
304
|
return null;
|
|
292
305
|
}
|
|
293
|
-
function configHasExpectedHooks(host, bytes,
|
|
306
|
+
function configHasExpectedHooks(host, bytes, hookExpression, path) {
|
|
294
307
|
const parsed = parseJsonObject(bytes, path);
|
|
295
308
|
if (!parsed.ok)
|
|
296
309
|
return false;
|
|
297
|
-
const patch = renderHookConfigPatch(host,
|
|
310
|
+
const patch = renderHookConfigPatch(host, hookExpression);
|
|
298
311
|
const hooks = parsed.value['hooks'];
|
|
299
312
|
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
|
|
300
313
|
return false;
|
|
@@ -329,7 +342,7 @@ function configHasExpectedHooks(host, bytes, runtimeEntry, path) {
|
|
|
329
342
|
}
|
|
330
343
|
return true;
|
|
331
344
|
}
|
|
332
|
-
function bundleManifestContent(bundle, hostRoot,
|
|
345
|
+
function bundleManifestContent(bundle, hostRoot, hookExpression, configPath, files, observedAt) {
|
|
333
346
|
return Buffer.from(`${JSON.stringify({
|
|
334
347
|
schema: 'pappra/host-install/1',
|
|
335
348
|
host: bundle.host,
|
|
@@ -337,7 +350,7 @@ function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files
|
|
|
337
350
|
bundleDigest: bundle.bundleDigest,
|
|
338
351
|
installedAt: observedAt,
|
|
339
352
|
hostRoot,
|
|
340
|
-
runtimeEntry,
|
|
353
|
+
runtimeEntry: hookExpression,
|
|
341
354
|
configPath,
|
|
342
355
|
files: files.map((file) => ({
|
|
343
356
|
path: file.path,
|
|
@@ -352,11 +365,13 @@ function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files
|
|
|
352
365
|
trustRequired: true,
|
|
353
366
|
}, null, 2)}\n`);
|
|
354
367
|
}
|
|
355
|
-
async function hostPlanFiles(host, hostRoot,
|
|
368
|
+
async function hostPlanFiles(host, workspaceRoot, hostRoot, hookSource, observedAt, migrate = false) {
|
|
369
|
+
const target = hookTarget(host, workspaceRoot, hostRoot, hookSource);
|
|
356
370
|
let payload;
|
|
357
371
|
try {
|
|
358
372
|
payload = await loadHostBundle(host, getPackageInfo().version, {
|
|
359
|
-
|
|
373
|
+
hookSource,
|
|
374
|
+
hookExpression: target.expression,
|
|
360
375
|
observedAt,
|
|
361
376
|
});
|
|
362
377
|
}
|
|
@@ -401,7 +416,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
|
|
|
401
416
|
diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host manifest is invalid and was preserved: ${manifestPath}`, manifestPath, 'Fix or remove the manifest explicitly; no managed file was overwritten.'),
|
|
402
417
|
};
|
|
403
418
|
}
|
|
404
|
-
const identityError = validateManifestIdentity(parsed.data, host, hostRoot, hostConfigRelativePath(host),
|
|
419
|
+
const identityError = validateManifestIdentity(parsed.data, host, hostRoot, hostConfigRelativePath(host), target.expression, migrate);
|
|
405
420
|
if (identityError !== null) {
|
|
406
421
|
return { ok: false, conflict: true, diagnostic: identityError };
|
|
407
422
|
}
|
|
@@ -477,7 +492,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
|
|
|
477
492
|
};
|
|
478
493
|
}
|
|
479
494
|
const before = existing.ok ? existing.value.bytes : null;
|
|
480
|
-
const merged = managedConfig(host === 'codex' ? 'codex' : 'claude', before,
|
|
495
|
+
const merged = managedConfig(host === 'codex' ? 'codex' : 'claude', before, target.expression, configPath);
|
|
481
496
|
if (!merged.ok) {
|
|
482
497
|
return {
|
|
483
498
|
...merged,
|
|
@@ -510,7 +525,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
|
|
|
510
525
|
})) {
|
|
511
526
|
installedAt = installedManifest.installedAt;
|
|
512
527
|
}
|
|
513
|
-
const manifest = bundleManifestContent(payload.bundle, hostRoot,
|
|
528
|
+
const manifest = bundleManifestContent(payload.bundle, hostRoot, target.expression, hostConfigRelativePath(host), files, installedAt);
|
|
514
529
|
contents.set(BUNDLE_FILE, manifest);
|
|
515
530
|
files.push({
|
|
516
531
|
path: BUNDLE_FILE,
|
|
@@ -527,14 +542,14 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
|
|
|
527
542
|
configPath,
|
|
528
543
|
};
|
|
529
544
|
}
|
|
530
|
-
function planFromFiles(host, workspaceRoot, hostRoot,
|
|
545
|
+
function planFromFiles(host, workspaceRoot, hostRoot, hookExpression, bundleDigest, files) {
|
|
531
546
|
return hostIntegrationPlanSchema.parse({
|
|
532
547
|
schema: 'pappra/host-integration/1',
|
|
533
548
|
planId: randomUUID(),
|
|
534
549
|
host,
|
|
535
550
|
workspaceRoot,
|
|
536
551
|
hostRoot,
|
|
537
|
-
runtimeEntry,
|
|
552
|
+
runtimeEntry: hookExpression,
|
|
538
553
|
bundleDigest,
|
|
539
554
|
files,
|
|
540
555
|
reloadRequired: true,
|
|
@@ -854,25 +869,47 @@ export class HostIntegrationService {
|
|
|
854
869
|
}
|
|
855
870
|
async plan(host, options = {}) {
|
|
856
871
|
const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
|
|
857
|
-
const
|
|
872
|
+
const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
|
|
858
873
|
const version = options.hostVersion ?? this.#hostVersion;
|
|
859
874
|
if (!isSupportedVersion(host, version)) {
|
|
860
875
|
throw new Error(`Host ${host} version ${version ?? '(unknown)'} is outside the pinned support matrix.`);
|
|
861
876
|
}
|
|
862
|
-
if (!(await runtimeEntryReady(
|
|
863
|
-
throw new Error(`
|
|
877
|
+
if (!(await runtimeEntryReady(hookSource))) {
|
|
878
|
+
throw new Error(`Bundled Pappra hook is missing: ${hookSource}`);
|
|
864
879
|
}
|
|
865
|
-
const
|
|
880
|
+
const target = hookTarget(host, this.#root, hostRoot, hookSource);
|
|
881
|
+
const built = await hostPlanFiles(host, this.#root, hostRoot, hookSource, this.#observedAt, options.migrate === true);
|
|
866
882
|
if (!built.ok)
|
|
867
883
|
throw new Error(built.diagnostic.message);
|
|
868
|
-
return planFromFiles(host, this.#root, hostRoot,
|
|
884
|
+
return planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
|
|
869
885
|
}
|
|
870
886
|
async install(host, options) {
|
|
887
|
+
if (options.mode === 'plugin') {
|
|
888
|
+
const support = HOST_PLUGIN_HOOKS[host];
|
|
889
|
+
const guidance = host === 'claude' && support.state === 'verified'
|
|
890
|
+
? 'Export with `pappra host bundle --host claude --out <dir> --marketplace --json`, then run `claude plugin marketplace add <dir>` and `claude plugin install pappra@pappra-local`. Use --mode project-settings for automatic workspace wiring.'
|
|
891
|
+
: host === 'pi' && support.state === 'verified'
|
|
892
|
+
? 'Export with `pappra host bundle --host pi --out <dir> --json`, then run `pi install <dir> -l -a`. Use --mode project-settings for automatic workspace wiring.'
|
|
893
|
+
: support.state === 'unsupported'
|
|
894
|
+
? 'This host does not load plugin hooks at its pinned version; keep --mode project-settings. See docs/host-plugin-validation.md.'
|
|
895
|
+
: 'Plugin hooks are not verified for this host yet; an authenticated lifecycle session is required. Use --mode project-settings meanwhile.';
|
|
896
|
+
return hostIntegrationResultSchema.parse({
|
|
897
|
+
schema: 'pappra/host-integration/1',
|
|
898
|
+
ok: false,
|
|
899
|
+
status: 'unsupported',
|
|
900
|
+
plan: null,
|
|
901
|
+
appliedFiles: [],
|
|
902
|
+
residualPaths: [],
|
|
903
|
+
diagnostics: [
|
|
904
|
+
diagnostic(diagnosticCodes.host_unavailable, `Plugin install mode is not wired for ${host}: ${support.state}. ${support.evidence}`, this.#root, guidance),
|
|
905
|
+
],
|
|
906
|
+
});
|
|
907
|
+
}
|
|
871
908
|
let plan;
|
|
872
909
|
let contents;
|
|
873
910
|
try {
|
|
874
911
|
const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
|
|
875
|
-
const
|
|
912
|
+
const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
|
|
876
913
|
const version = options.hostVersion ?? this.#hostVersion;
|
|
877
914
|
if (!isSupportedVersion(host, version)) {
|
|
878
915
|
return hostIntegrationResultSchema.parse({
|
|
@@ -887,7 +924,7 @@ export class HostIntegrationService {
|
|
|
887
924
|
],
|
|
888
925
|
});
|
|
889
926
|
}
|
|
890
|
-
if (!(await runtimeEntryReady(
|
|
927
|
+
if (!(await runtimeEntryReady(hookSource))) {
|
|
891
928
|
return hostIntegrationResultSchema.parse({
|
|
892
929
|
schema: 'pappra/host-integration/1',
|
|
893
930
|
ok: false,
|
|
@@ -896,11 +933,12 @@ export class HostIntegrationService {
|
|
|
896
933
|
appliedFiles: [],
|
|
897
934
|
residualPaths: [],
|
|
898
935
|
diagnostics: [
|
|
899
|
-
diagnostic(diagnosticCodes.host_unavailable, `
|
|
936
|
+
diagnostic(diagnosticCodes.host_unavailable, `Bundled Pappra hook is missing: ${hookSource}`, hookSource, 'Install or select a complete Pappra runtime before wiring a host.'),
|
|
900
937
|
],
|
|
901
938
|
});
|
|
902
939
|
}
|
|
903
|
-
const
|
|
940
|
+
const target = hookTarget(host, this.#root, hostRoot, hookSource);
|
|
941
|
+
const built = await hostPlanFiles(host, this.#root, hostRoot, hookSource, this.#observedAt, options.migrate === true);
|
|
904
942
|
if (!built.ok) {
|
|
905
943
|
return hostIntegrationResultSchema.parse({
|
|
906
944
|
schema: 'pappra/host-integration/1',
|
|
@@ -912,7 +950,7 @@ export class HostIntegrationService {
|
|
|
912
950
|
diagnostics: [built.diagnostic],
|
|
913
951
|
});
|
|
914
952
|
}
|
|
915
|
-
plan = planFromFiles(host, this.#root, hostRoot,
|
|
953
|
+
plan = planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
|
|
916
954
|
contents = built.contents;
|
|
917
955
|
}
|
|
918
956
|
catch (error) {
|
|
@@ -1138,7 +1176,8 @@ export class HostIntegrationService {
|
|
|
1138
1176
|
});
|
|
1139
1177
|
}
|
|
1140
1178
|
const expectedConfigPath = hostConfigRelativePath(host);
|
|
1141
|
-
const
|
|
1179
|
+
const target = hookTarget(host, this.#root, hostRoot, this.#runtimeEntry);
|
|
1180
|
+
const identityError = validateManifestIdentity(manifest, host, hostRoot, expectedConfigPath, target.expression, false);
|
|
1142
1181
|
if (identityError !== null) {
|
|
1143
1182
|
return hostVerificationSchema.parse({
|
|
1144
1183
|
schema: 'pappra/host-verification/1',
|
|
@@ -1162,7 +1201,8 @@ export class HostIntegrationService {
|
|
|
1162
1201
|
let currentBundle;
|
|
1163
1202
|
try {
|
|
1164
1203
|
currentBundle = await loadHostBundle(host, getPackageInfo().version, {
|
|
1165
|
-
|
|
1204
|
+
hookSource: this.#runtimeEntry,
|
|
1205
|
+
hookExpression: target.expression,
|
|
1166
1206
|
observedAt: this.#observedAt,
|
|
1167
1207
|
});
|
|
1168
1208
|
}
|
|
@@ -1306,7 +1346,7 @@ export class HostIntegrationService {
|
|
|
1306
1346
|
const config = await readFileBounded(configPath, DEFAULT_MAX_CONFIG_BYTES);
|
|
1307
1347
|
if (!config.ok ||
|
|
1308
1348
|
manifestFiles.get(manifest.configPath) !== config.value.digest ||
|
|
1309
|
-
!configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes,
|
|
1349
|
+
!configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes, target.expression, configPath)) {
|
|
1310
1350
|
status = 'conflict';
|
|
1311
1351
|
checks.push({
|
|
1312
1352
|
id: 'host.config',
|
|
@@ -3,6 +3,7 @@ export declare const HOST_SUPPORT: {
|
|
|
3
3
|
readonly pi: {
|
|
4
4
|
readonly surface: 'pi-extension';
|
|
5
5
|
readonly version: '1.3.0';
|
|
6
|
+
readonly hookScope: 'project';
|
|
6
7
|
readonly events: {
|
|
7
8
|
readonly startup: 'session_start';
|
|
8
9
|
readonly resume: 'session_start';
|
|
@@ -13,6 +14,7 @@ export declare const HOST_SUPPORT: {
|
|
|
13
14
|
readonly codex: {
|
|
14
15
|
readonly surface: 'codex-hooks';
|
|
15
16
|
readonly version: '0.163.0';
|
|
17
|
+
readonly hookScope: 'user';
|
|
16
18
|
readonly events: {
|
|
17
19
|
readonly startup: 'SessionStart';
|
|
18
20
|
readonly resume: 'SessionStart';
|
|
@@ -23,6 +25,7 @@ export declare const HOST_SUPPORT: {
|
|
|
23
25
|
readonly claude: {
|
|
24
26
|
readonly surface: 'claude-hooks';
|
|
25
27
|
readonly version: '2.1.292';
|
|
28
|
+
readonly hookScope: 'project';
|
|
26
29
|
readonly events: {
|
|
27
30
|
readonly startup: 'SessionStart';
|
|
28
31
|
readonly resume: 'SessionStart';
|
|
@@ -34,3 +37,23 @@ export declare const HOST_SUPPORT: {
|
|
|
34
37
|
export declare function nativeEventFor(host: HostName, event: HostLifecycleEvent): string | null;
|
|
35
38
|
export declare function isSupportedVersion(host: HostName, version: string | null): boolean;
|
|
36
39
|
export declare function surfaceFor(host: HostName): (typeof HOST_SUPPORT)[HostName]['surface'];
|
|
40
|
+
/**
|
|
41
|
+
* Observed plugin-hook support. `verified` means a real host session produced a
|
|
42
|
+
* `.pappra/local/host-events/<host>` record; `unsupported` means a control hook
|
|
43
|
+
* fired while the bundled plugin hook did not; `unverified` means no
|
|
44
|
+
* authenticated session was available. See docs/host-plugin-validation.md.
|
|
45
|
+
*/
|
|
46
|
+
export declare const HOST_PLUGIN_HOOKS: {
|
|
47
|
+
readonly pi: {
|
|
48
|
+
readonly state: 'verified';
|
|
49
|
+
readonly evidence: 'Pi 1.3.0 fires the packaged extension: `pi install <dir> -l -a` plus `pi -p -a` wrote a session_start event.';
|
|
50
|
+
};
|
|
51
|
+
readonly codex: {
|
|
52
|
+
readonly state: 'unsupported';
|
|
53
|
+
readonly evidence: 'Codex 0.163.0 does not load plugin hooks: a control hook in CODEX_HOME fired during `codex exec --dangerously-bypass-hook-trust` while the bundled plugin hook never ran, including with a .codex-plugin/plugin.json copy.';
|
|
54
|
+
};
|
|
55
|
+
readonly claude: {
|
|
56
|
+
readonly state: 'verified';
|
|
57
|
+
readonly evidence: 'Claude Code 2.1.292 passed `claude plugin validate`, then a project-scope marketplace install and `claude -p` wrote a SessionStart event from ${CLAUDE_PLUGIN_ROOT}/hooks/pappra-hook.mjs.';
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -2,6 +2,7 @@ export const HOST_SUPPORT = {
|
|
|
2
2
|
pi: {
|
|
3
3
|
surface: 'pi-extension',
|
|
4
4
|
version: '1.3.0',
|
|
5
|
+
hookScope: 'project',
|
|
5
6
|
events: {
|
|
6
7
|
startup: 'session_start',
|
|
7
8
|
resume: 'session_start',
|
|
@@ -12,6 +13,9 @@ export const HOST_SUPPORT = {
|
|
|
12
13
|
codex: {
|
|
13
14
|
surface: 'codex-hooks',
|
|
14
15
|
version: '0.163.0',
|
|
16
|
+
// Codex 0.163.0 loads lifecycle hooks only from its user configuration
|
|
17
|
+
// directory (CODEX_HOME); project `.codex/hooks.json` is not loaded.
|
|
18
|
+
hookScope: 'user',
|
|
15
19
|
events: {
|
|
16
20
|
startup: 'SessionStart',
|
|
17
21
|
resume: 'SessionStart',
|
|
@@ -22,6 +26,7 @@ export const HOST_SUPPORT = {
|
|
|
22
26
|
claude: {
|
|
23
27
|
surface: 'claude-hooks',
|
|
24
28
|
version: '2.1.292',
|
|
29
|
+
hookScope: 'project',
|
|
25
30
|
events: {
|
|
26
31
|
startup: 'SessionStart',
|
|
27
32
|
resume: 'SessionStart',
|
|
@@ -39,3 +44,23 @@ export function isSupportedVersion(host, version) {
|
|
|
39
44
|
export function surfaceFor(host) {
|
|
40
45
|
return HOST_SUPPORT[host].surface;
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Observed plugin-hook support. `verified` means a real host session produced a
|
|
49
|
+
* `.pappra/local/host-events/<host>` record; `unsupported` means a control hook
|
|
50
|
+
* fired while the bundled plugin hook did not; `unverified` means no
|
|
51
|
+
* authenticated session was available. See docs/host-plugin-validation.md.
|
|
52
|
+
*/
|
|
53
|
+
export const HOST_PLUGIN_HOOKS = {
|
|
54
|
+
pi: {
|
|
55
|
+
state: 'verified',
|
|
56
|
+
evidence: 'Pi 1.3.0 fires the packaged extension: `pi install <dir> -l -a` plus `pi -p -a` wrote a session_start event.',
|
|
57
|
+
},
|
|
58
|
+
codex: {
|
|
59
|
+
state: 'unsupported',
|
|
60
|
+
evidence: 'Codex 0.163.0 does not load plugin hooks: a control hook in CODEX_HOME fired during `codex exec --dangerously-bypass-hook-trust` while the bundled plugin hook never ran, including with a .codex-plugin/plugin.json copy.',
|
|
61
|
+
},
|
|
62
|
+
claude: {
|
|
63
|
+
state: 'verified',
|
|
64
|
+
evidence: 'Claude Code 2.1.292 passed `claude plugin validate`, then a project-scope marketplace install and `claude -p` wrote a SessionStart event from ${CLAUDE_PLUGIN_ROOT}/hooks/pappra-hook.mjs.',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -5,7 +5,7 @@ export interface RenderedHostTemplate {
|
|
|
5
5
|
readonly bytes: Buffer;
|
|
6
6
|
readonly kind: 'extension' | 'hook';
|
|
7
7
|
}
|
|
8
|
-
export declare function renderHookCommand(host: HostName, event: HostLifecycleEvent,
|
|
8
|
+
export declare function renderHookCommand(host: HostName, event: HostLifecycleEvent, hookExpression: string): string;
|
|
9
9
|
interface HookEntry {
|
|
10
10
|
readonly hooks: readonly {
|
|
11
11
|
readonly type: 'command';
|
|
@@ -13,10 +13,10 @@ interface HookEntry {
|
|
|
13
13
|
readonly timeout: number;
|
|
14
14
|
}[];
|
|
15
15
|
}
|
|
16
|
-
export declare function renderHookConfigPatch(host: 'codex' | 'claude',
|
|
16
|
+
export declare function renderHookConfigPatch(host: 'codex' | 'claude', hookExpression: string): {
|
|
17
17
|
readonly hooks: Record<string, readonly HookEntry[]>;
|
|
18
18
|
};
|
|
19
|
-
export declare function renderHostTemplate(host: HostName,
|
|
19
|
+
export declare function renderHostTemplate(host: HostName, hookExpression: string): RenderedHostTemplate;
|
|
20
20
|
export declare function hostConfigRelativePath(host: HostName): 'settings.json' | 'hooks.json' | null;
|
|
21
21
|
export declare function hostConfigName(host: HostName): string;
|
|
22
22
|
export {};
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, dirname } from 'node:path';
|
|
1
|
+
import { basename } from 'node:path';
|
|
3
2
|
export const PAPPRA_MANAGED_MARKER = 'pappra-managed';
|
|
4
3
|
function shellQuote(value) {
|
|
5
4
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
6
5
|
}
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Host placeholders such as ${CLAUDE_PROJECT_DIR} only expand inside double
|
|
8
|
+
* quotes; absolute paths are single-quoted to survive spaces and quotes.
|
|
9
|
+
*/
|
|
10
|
+
function pathExpression(value) {
|
|
11
|
+
return value.includes('${') ? `"${value}"` : shellQuote(value);
|
|
12
|
+
}
|
|
13
|
+
export function renderHookCommand(host, event, hookExpression) {
|
|
14
|
+
return `${shellQuote(process.execPath)} ${pathExpression(hookExpression)} ${shellQuote(host)} ${shellQuote(event)} # ${PAPPRA_MANAGED_MARKER}`;
|
|
9
15
|
}
|
|
10
|
-
export function renderHookConfigPatch(host,
|
|
16
|
+
export function renderHookConfigPatch(host, hookExpression) {
|
|
11
17
|
const entry = (event) => ({
|
|
12
18
|
hooks: [
|
|
13
19
|
{
|
|
14
20
|
type: 'command',
|
|
15
|
-
command: renderHookCommand(host, event,
|
|
21
|
+
command: renderHookCommand(host, event, hookExpression),
|
|
16
22
|
timeout: 1,
|
|
17
23
|
},
|
|
18
24
|
],
|
|
@@ -30,15 +36,14 @@ export function renderHookConfigPatch(host, runtimeEntry) {
|
|
|
30
36
|
},
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
|
-
export function renderHostTemplate(host,
|
|
39
|
+
export function renderHostTemplate(host, hookExpression) {
|
|
34
40
|
if (host === 'pi') {
|
|
35
|
-
const runtimeUrl = pathToFileURL(`${dirname(runtimeEntry)}/pi-extension-runtime.js`).href;
|
|
36
41
|
const source = `/**
|
|
37
42
|
* Generated Pappra Pi extension. It registers bounded local lifecycle handlers;
|
|
38
43
|
* it does not start processes, models, network requests or background work.
|
|
39
44
|
*/
|
|
40
45
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
41
|
-
import { createPiExtension } from
|
|
46
|
+
import { createPiExtension } from "./runtime.mjs";
|
|
42
47
|
|
|
43
48
|
export default function (pi: ExtensionAPI): void {
|
|
44
49
|
createPiExtension(pi);
|
|
@@ -50,7 +55,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
50
55
|
kind: 'extension',
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
|
-
const patch = renderHookConfigPatch(host,
|
|
58
|
+
const patch = renderHookConfigPatch(host, hookExpression);
|
|
54
59
|
return {
|
|
55
60
|
path: `templates/${host}/hooks.json`,
|
|
56
61
|
bytes: Buffer.from(`${JSON.stringify(patch, null, 2)}\n`),
|
package/dist/hosts/pi/rpc.d.ts
CHANGED
|
@@ -461,6 +461,8 @@ export declare function startOwnedPiRpc(options: PiOptions): Promise<{
|
|
|
461
461
|
pid: number | null;
|
|
462
462
|
expectedSessionId: string | null;
|
|
463
463
|
sessionFile: () => string | null;
|
|
464
|
+
/** Byte offset sampled immediately before the prompt was sent. */
|
|
465
|
+
promptBaseline: () => number;
|
|
464
466
|
/** Read-only native query. Repeatable; always recorded through the protocol. */
|
|
465
467
|
query(): Promise<{
|
|
466
468
|
at: string;
|
package/dist/hosts/pi/rpc.js
CHANGED
|
@@ -680,6 +680,7 @@ export async function startOwnedPiRpc(options) {
|
|
|
680
680
|
let reportedSessionFile = null;
|
|
681
681
|
let boundSessionFile = null;
|
|
682
682
|
let promptSent = false;
|
|
683
|
+
let promptBaseline = 0;
|
|
683
684
|
let lastPromptOrdinal = 0;
|
|
684
685
|
let operationError = null;
|
|
685
686
|
let finishPromise = null;
|
|
@@ -822,6 +823,8 @@ export async function startOwnedPiRpc(options) {
|
|
|
822
823
|
pid: child.pid ?? null,
|
|
823
824
|
expectedSessionId: admission.expected_identity,
|
|
824
825
|
sessionFile: () => boundSessionFile,
|
|
826
|
+
/** Byte offset sampled immediately before the prompt was sent. */
|
|
827
|
+
promptBaseline: () => promptBaseline,
|
|
825
828
|
/** Read-only native query. Repeatable; always recorded through the protocol. */
|
|
826
829
|
async query() {
|
|
827
830
|
try {
|
|
@@ -894,7 +897,24 @@ export async function startOwnedPiRpc(options) {
|
|
|
894
897
|
throw new PiRpcRefusal('prompt_already_sent', 'invalid', 'one frozen prompt per launch');
|
|
895
898
|
assertNamespaceGuard(opts);
|
|
896
899
|
assertCurrentAuthority({ requireResources: true });
|
|
897
|
-
|
|
900
|
+
// Pi creates the session file lazily, on the first persisted message. The
|
|
901
|
+
// pre-prompt check therefore verifies the reported session id without requiring
|
|
902
|
+
// the header; the post-prompt check requires the bound header before the run is
|
|
903
|
+
// accepted.
|
|
904
|
+
await assertIdentity({ requireBinding: false });
|
|
905
|
+
// The controller publishes only records written after this baseline, so it must
|
|
906
|
+
// be sampled before the prompt creates the first message.
|
|
907
|
+
const baselineFile = reportedSessionFile;
|
|
908
|
+
promptBaseline = (() => {
|
|
909
|
+
if (baselineFile === null)
|
|
910
|
+
return 0;
|
|
911
|
+
try {
|
|
912
|
+
return fs.statSync(baselineFile).size;
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
return 0;
|
|
916
|
+
}
|
|
917
|
+
})();
|
|
898
918
|
const ordinalBefore = rpc.recordCount();
|
|
899
919
|
const response = await request('prompt', { message: opts.promptText });
|
|
900
920
|
const disposition = response.data?.disposition;
|
|
@@ -902,6 +922,24 @@ export async function startOwnedPiRpc(options) {
|
|
|
902
922
|
!['started', 'queued', 'handled'].includes(disposition)) {
|
|
903
923
|
throw new PiRpcRefusal('prompt_not_accepted', 'needs_input', `disposition ${disposition}`);
|
|
904
924
|
}
|
|
925
|
+
// The header row is flushed just after the prompt is accepted, so bind with a
|
|
926
|
+
// short bounded retry instead of racing the writer.
|
|
927
|
+
const bindDeadline = Date.now() + 2_000;
|
|
928
|
+
for (;;) {
|
|
929
|
+
try {
|
|
930
|
+
await assertIdentity({ requireBinding: true });
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
const retryable = error instanceof PiRpcRefusal &&
|
|
935
|
+
(error.code === 'native_session_file_unavailable' ||
|
|
936
|
+
error.code === 'native_session_header_invalid') &&
|
|
937
|
+
Date.now() < bindDeadline;
|
|
938
|
+
if (!retryable)
|
|
939
|
+
throw error;
|
|
940
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
905
943
|
promptSent = true;
|
|
906
944
|
lastPromptOrdinal = ordinalBefore;
|
|
907
945
|
proof.prompt = {
|