@tekmidian/pai 1.0.4 → 1.0.6
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/.claude-plugin/plugin.json +1 -1
- package/dist/{aibroker-client-CQPGqLxL.mjs → aibroker-client-CXsyC7A8.mjs} +2 -2
- package/dist/aibroker-client-ChB7gSxV.mjs +3 -0
- package/dist/auto-route-Bc8xP6TQ.mjs +11 -0
- package/dist/{auto-route-21GdHL70.mjs → auto-route-YaEGVrFl.mjs} +2 -2
- package/dist/budget-DEiebT2A.mjs +15 -0
- package/dist/{caller-identity-yLFb7CKq.mjs → caller-identity-C6Nc96iM.mjs} +4 -4
- package/dist/{capacity-DEveQdFV.mjs → capacity-B2PgKAg4.mjs} +69 -6
- package/dist/{chain-D7PFur8Y.mjs → chain-CtK49-Ma.mjs} +5 -5
- package/dist/cli/index.mjs +2126 -290
- package/dist/{cockpit-actions-BfRMr2sm.mjs → cockpit-actions-C73mJAJ8.mjs} +18 -18
- package/dist/{cockpit-actions-OodH_vx_.mjs → cockpit-actions-D1OPP6Es.mjs} +30 -26
- package/dist/cockpit-memory-CLHZD64W.mjs +20 -0
- package/dist/config/defaults/config.yaml +21 -1
- package/dist/{config-XpZWOYOG.mjs → config-Bz8pAU5Q.mjs} +30 -5
- package/dist/{config-DtENJ6cL.mjs → config-DHgPx3ER.mjs} +1 -1
- package/dist/config-DLmyEK2q.mjs +4 -0
- package/dist/{config-Bu1_vbY8.mjs → config-SlZ-Z52i.mjs} +4 -3
- package/dist/{controls-JLm6g0yP.mjs → controls-rIItFeIJ.mjs} +59 -66
- package/dist/daemon/index.mjs +40 -39
- package/dist/daemon-176qA79N.mjs +46 -0
- package/dist/{daemon-nXanenDM.mjs → daemon-SoUvPUOf.mjs} +80 -30
- package/dist/daemon-mcp/index.mjs +49 -38
- package/dist/detect-C8nFP-kR.mjs +10 -0
- package/dist/{detect-BLKsUOgG.mjs → detect-DLjeYwnt.mjs} +2 -2
- package/dist/{embed-pass-e64AwDCa.mjs → embed-pass-CN1D8MXl.mjs} +5 -5
- package/dist/{embedding-gate-Cnk4YCyX.mjs → embedding-gate-BVQvUmox.mjs} +2 -2
- package/dist/{embedding-gate-6rZrWRXa.mjs → embedding-gate-iq0oQEkF.mjs} +3 -3
- package/dist/{endreason-BC-v05H_.mjs → endreason-C2-hYK0_.mjs} +4 -4
- package/dist/{factory-B7nv75rC.mjs → factory-CYwa0jYj.mjs} +6 -6
- package/dist/factory-DFwez99M.mjs +9 -0
- package/dist/{fallback-CBWpp2F2.mjs → fallback-CwPSCrX8.mjs} +9 -231
- package/dist/{health-C4FQmkJn.mjs → health-D0mUAVdw.mjs} +5 -5
- package/dist/{helpers-DSNK_1KP.mjs → helpers-Dj2nY56t.mjs} +2 -2
- package/dist/{helpers-Dz7mjTSw.mjs → helpers-DyHKAwrW.mjs} +6 -6
- package/dist/hooks/block-sleep-poll.mjs +1 -1
- package/dist/hooks/capture-all-events.mjs +68 -3
- package/dist/hooks/capture-tool-output.mjs +68 -3
- package/dist/hooks/handoff-link.mjs +128 -0
- package/dist/hooks/load-project-context.mjs +8 -8
- package/dist/hooks/local-model-hint.mjs +1 -1
- package/dist/hooks/route-agents-to-worker.mjs +1 -1
- package/dist/hooks/security-validator.mjs +842 -90
- package/dist/hooks/supervision-banner.mjs +186 -48
- package/dist/hooks/worker-edit-repair.mjs +2 -2
- package/dist/hooks/worker-guard.mjs +65 -9
- package/dist/hooks/worker-note.mjs +70 -5
- package/dist/hooks/worker-pool-host.mjs +1518 -265
- package/dist/hooks/worker-proxy.mjs +1 -1
- package/dist/hooks/worker-status-line.mjs +225 -86
- package/dist/hooks/worker-supervision.mjs +1 -1
- package/dist/hooks/worker-tool-name-repair.mjs +2 -2
- package/dist/index.mjs +9 -9
- package/dist/{ipc-client-CnYEF31R.mjs → ipc-client-CmagIFLh.mjs} +2 -2
- package/dist/{ipc-client-DN7EvoVo.mjs → ipc-client-DmbSIBUK.mjs} +1 -1
- package/dist/{kill-tree-DleMNZX3.mjs → kill-tree-DPiMscaY.mjs} +8 -8
- package/dist/kill-tree-Dxei6qlR.mjs +10 -0
- package/dist/{launch-DlZTKE3u.mjs → launch-BZT3JDoZ.mjs} +3 -3
- package/dist/{launch-Di7N-J5X.mjs → launch-Bn2Zf90h.mjs} +3 -3
- package/dist/{indexer-backend-BdjeL4P8.mjs → lessons-CFVU0cwA.mjs} +262 -43
- package/dist/{local-candidate-DLdwQt0s.mjs → local-candidate-DFd4pp-w.mjs} +3 -3
- package/dist/{local-instances-DOxSgT_L.mjs → local-instances-DhF4_sV2.mjs} +2 -2
- package/dist/{local-instances-ZeRkkuS5.mjs → local-instances-KTh8IeOT.mjs} +4 -4
- package/dist/{local-tiers-BE4jGaMa.mjs → local-tiers-CqwGUcEn.mjs} +3 -3
- package/dist/{local-tiers-DNMI5W86.mjs → local-tiers-DSlIYvvm.mjs} +3 -3
- package/dist/{main-config-ops-kTXR8Pyv.mjs → main-config-ops-rda08BCD.mjs} +3 -3
- package/dist/{main-resolver-yp_puSSb.mjs → main-resolver-BKwI0I6H.mjs} +18 -18
- package/dist/{migrate-DrA_OwlZ.mjs → migrate-C2z1YmWC.mjs} +2 -2
- package/dist/{model-inventory-h6Z9fN_o.mjs → model-inventory-BXv15B0d.mjs} +13 -13
- package/dist/{models-add-TVni3A7V.mjs → models-add-5yXTyz3g.mjs} +38 -37
- package/dist/operator-Bmv5tiHQ.mjs +7 -0
- package/dist/{operator-CyuB3aVw.mjs → operator-Br6GeswF.mjs} +2 -2
- package/dist/{pai-marker-BcZyJguM.mjs → pai-marker-D6OoVbM_.mjs} +2 -2
- package/dist/{pane-audit-Lqy9N_Ki.mjs → pane-audit-DLYy9-4g.mjs} +13 -23
- package/dist/{paths-CHifubDh.mjs → paths-CypxFTfn.mjs} +4 -4
- package/dist/{paths-CFHTloSe.mjs → paths-DIOwNcj-.mjs} +3 -3
- package/dist/{planner-j77ndkBf.mjs → planner-BzZ_2lc-.mjs} +29 -29
- package/dist/{pool-Dh-q3iJi.mjs → pool-9EsbKD-U.mjs} +2 -2
- package/dist/{pool-cli-DQ6OzRy2.mjs → pool-cli-Cy5mlycP.mjs} +6 -6
- package/dist/{pool-cli-BggU8GRp.mjs → pool-cli-Db0uDwB9.mjs} +5 -5
- package/dist/{postgres-BK4FP9Zg.mjs → postgres-B1B80bcj.mjs} +3 -3
- package/dist/{postgres-DNIOQijl.mjs → postgres-BL5oBBgY.mjs} +3 -3
- package/dist/{project-setup-CBRtFFh0.mjs → project-setup-DHxdJjEc.mjs} +24 -25
- package/dist/providers-BH6hQYbU.mjs +16 -0
- package/dist/{providers-C9QC0TtO.mjs → providers-BNV7CzC-.mjs} +6 -6
- package/dist/{query-feedback-DN52N9C5.mjs → query-feedback-BWl2HRwN.mjs} +2 -2
- package/dist/registrable-BwFiqazy.mjs +9 -0
- package/dist/{registrable-DpyTtajg.mjs → registrable-Dxs4oKIj.mjs} +4 -4
- package/dist/render-DVGXpfaT.mjs +13 -0
- package/dist/render-jPpMrudf.mjs +2240 -0
- package/dist/root-C8cxhdn9.mjs +36 -0
- package/dist/{root-C4oXlXS_.mjs → root-DmOzMeSv.mjs} +10 -10
- package/dist/route-table-COAmc1nB.mjs +15 -0
- package/dist/{router-CQSc3lk3.mjs → router-D2476pOp.mjs} +2 -2
- package/dist/{router-DwHJRgG9.mjs → router-ghujlEFb.mjs} +1 -1
- package/dist/{routing-f2zulGwD.mjs → routing-B_cg1_Xx.mjs} +13 -13
- package/dist/{routing-DmkheCRH.mjs → routing-aJKMIagQ.mjs} +16 -16
- package/dist/{run-BwZKBJZZ.mjs → run-DE9Q0ldN.mjs} +55 -31
- package/dist/{runtime-paths-WsO9sJFc.mjs → runtime-paths-twfV23YL.mjs} +2 -2
- package/dist/{scan-BBGvU5EB.mjs → scan-CNMcltOX.mjs} +7 -7
- package/dist/{scope-D-h6F6DN.mjs → scope-BTWstJ1B.mjs} +4 -4
- package/dist/session-DgZifjLx.mjs +35 -0
- package/dist/{session-Dn5OJXlg.mjs → session-HBYOABWU.mjs} +28 -28
- package/dist/session-scan-BZxnYN1v.mjs +9 -0
- package/dist/{session-scan-DUn10MBd.mjs → session-scan-CX3MKCNg.mjs} +6 -6
- package/dist/{session-usage-CPvXvSkw.mjs → session-usage-CCmPFFa6.mjs} +117 -2
- package/dist/{status-ggJ7lgVg.mjs → status-Cecau8Ps.mjs} +9 -5
- package/dist/status-D52xC_i0.mjs +6 -0
- package/dist/{tool-errors-7E13StWd.mjs → tool-errors-BFw1gv-z.mjs} +2 -2
- package/dist/{tool-errors-CJWODvlo.mjs → tool-errors-COM2JGEr.mjs} +6 -6
- package/dist/viewer-Ce-Xa9L3.mjs +22 -0
- package/dist/{viewer-wfKnvnrI.mjs → viewer-DvBg6N22.mjs} +16 -14
- package/dist/{wait-ByV0OyMZ.mjs → wait-C5iWILj_.mjs} +3 -3
- package/dist/worker-config-BmcJ9R0U.mjs +42 -0
- package/dist/{worker-config-eY5Qy4vl.mjs → worker-config-DkONNk_H.mjs} +164 -107
- package/dist/workers-config-DwzrtdYu.mjs +4 -0
- package/dist/{worktree-CRs2gyg0.mjs → worktree-O_kU6Q3y.mjs} +164 -8
- package/dist/{zettelkasten-tpCRBYvH.mjs → zettelkasten-BH8AdNh8.mjs} +4 -4
- package/dist/{zettelkasten-_h_tUm2T.mjs → zettelkasten-FFhQFESJ.mjs} +2 -2
- package/docs/commands/README.md +33 -2
- package/docs/commands/audit.md +34 -3
- package/docs/commands/backup.md +1 -1
- package/docs/commands/build.md +4 -4
- package/docs/commands/cd.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/cockpit.md +1 -1
- package/docs/commands/config.md +21 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/doc.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/find.md +1 -1
- package/docs/commands/git.md +110 -0
- package/docs/commands/handoff.md +70 -0
- package/docs/commands/harness.md +74 -0
- package/docs/commands/help.md +1 -1
- package/docs/commands/home.md +1 -1
- package/docs/commands/hooks-db.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/install.md +1 -1
- package/docs/commands/issue.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/launch.md +1 -1
- package/docs/commands/lessons.md +201 -0
- package/docs/commands/list.md +1 -1
- package/docs/commands/ls.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/models.md +1 -1
- package/docs/commands/more.md +1 -1
- package/docs/commands/new.md +1 -1
- package/docs/commands/notes.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/open.md +1 -1
- package/docs/commands/pane.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/plan.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/providers.md +1 -1
- package/docs/commands/queue.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/reload.md +1 -1
- package/docs/commands/rename.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/resume.md +1 -1
- package/docs/commands/router.md +1 -1
- package/docs/commands/rules.md +70 -0
- package/docs/commands/session.md +1 -1
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/setups.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/tasks.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +1 -1
- package/docs/commands/zettel.md +1 -1
- package/docs/concepts/panes.md +3 -2
- package/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/pai-plugin.json +1 -1
- package/plugins/context-preservation/plugin.json +1 -1
- package/plugins/core/plugin.json +1 -1
- package/plugins/creative/plugin.json +1 -1
- package/plugins/observability/plugin.json +1 -1
- package/plugins/productivity/plugin.json +1 -1
- package/plugins/semantic-search/plugin.json +1 -1
- package/plugins/ui/plugin.json +1 -1
- package/plugins/zettelkasten/plugin.json +1 -1
- package/src/hooks/ts/lib/loop-guard.ts +3 -5
- package/src/hooks/ts/lib/rules/builtin/base64-decode-to-shell.rule.yaml +13 -0
- package/src/hooks/ts/lib/rules/builtin/chmod-world-writable.rule.yaml +13 -0
- package/src/hooks/ts/lib/rules/builtin/chmod-zero.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/credential-file-read.rule.yaml +13 -0
- package/src/hooks/ts/lib/rules/builtin/credential-read-then-network.rule.yaml +29 -0
- package/src/hooks/ts/lib/rules/builtin/crontab-persistence.rule.yaml +10 -0
- package/src/hooks/ts/lib/rules/builtin/curl-piped-to-shell.rule.yaml +15 -0
- package/src/hooks/ts/lib/rules/builtin/download-then-execute.rule.yaml +30 -0
- package/src/hooks/ts/lib/rules/builtin/find-root-delete.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/git-push-force.rule.yaml +24 -0
- package/src/hooks/ts/lib/rules/builtin/git-push-plus-refspec.rule.yaml +12 -0
- package/src/hooks/ts/lib/rules/builtin/git-reset-hard.rule.yaml +12 -0
- package/src/hooks/ts/lib/rules/builtin/injection-then-tool.rule.yaml +30 -0
- package/src/hooks/ts/lib/rules/builtin/instruction-override-disregard.rule.yaml +12 -0
- package/src/hooks/ts/lib/rules/builtin/instruction-override-ignore.rule.yaml +13 -0
- package/src/hooks/ts/lib/rules/builtin/launchagents-write-persistence.rule.yaml +12 -0
- package/src/hooks/ts/lib/rules/builtin/launchctl-load-persistence.rule.yaml +10 -0
- package/src/hooks/ts/lib/rules/builtin/reverse-shell-dev-tcp.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/reverse-shell-interactive-bash.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/rm-recursive-parent.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/rm-recursive-relative.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/rm-recursive-root.rule.yaml +24 -0
- package/src/hooks/ts/lib/rules/builtin/rm-trailing-tilde.rule.yaml +11 -0
- package/src/hooks/ts/lib/rules/builtin/sudoers-nopasswd.rule.yaml +15 -0
- package/src/hooks/ts/lib/rules/builtin.generated.ts +530 -0
- package/src/hooks/ts/lib/rules/eval.ts +74 -0
- package/src/hooks/ts/lib/rules/lint.ts +102 -0
- package/src/hooks/ts/lib/rules/load.ts +229 -0
- package/src/hooks/ts/lib/rules/types.ts +56 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +27 -96
- package/src/hooks/ts/user-prompt/handoff-link.ts +34 -0
- package/dist/aibroker-client-BxzeEHPF.mjs +0 -3
- package/dist/auto-route-C3S2W3Bc.mjs +0 -11
- package/dist/budget-BrloTOZW.mjs +0 -15
- package/dist/cockpit-memory-teG9M8bG.mjs +0 -20
- package/dist/config-Cxi-aBf4.mjs +0 -4
- package/dist/daemon-DeYImF1F.mjs +0 -45
- package/dist/detect-Rrimh0xc.mjs +0 -10
- package/dist/factory-Bs9imVMq.mjs +0 -9
- package/dist/kill-tree-SV6hrIa3.mjs +0 -10
- package/dist/operator-BTqvTIc8.mjs +0 -7
- package/dist/providers-FvCLzFge.mjs +0 -16
- package/dist/registrable-BzH4TObg.mjs +0 -9
- package/dist/render-B35bQTE-.mjs +0 -13
- package/dist/render-C84BqQRC.mjs +0 -903
- package/dist/root-DD1xx_Vu.mjs +0 -36
- package/dist/route-table-FEfDuhIV.mjs +0 -15
- package/dist/session-BAhtXdxj.mjs +0 -35
- package/dist/session-scan-B57dNuA4.mjs +0 -9
- package/dist/status-Dm4dYciN.mjs +0 -6
- package/dist/viewer-DEySE4c_.mjs +0 -22
- package/dist/worker-config-CO5FXLAT.mjs +0 -41
- package/dist/workers-config-f8KrkY4L.mjs +0 -4
package/dist/cli/index.mjs
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { _ as warn$1, a as fmtDate, c as ok$1, d as scaffoldProjectDirs, f as shortenPath, g as stripAnsi, h as smartDecodeDir, i as err, l as renderTable$1, m as slugify, n as dim$1, o as header, r as encodeDir, s as now, t as bold$1, u as resolvePath } from "../utils-J2b9y8PC.mjs";
|
|
3
|
-
import { _ as
|
|
4
|
-
import { a as slugify$1, i as parseSessionFilename } from "../migrate-
|
|
5
|
-
import {
|
|
6
|
-
import { A as
|
|
7
|
-
import { i as ledgerPath, n as ensureNoMcpConfig, u as workersLogDir } from "../paths-
|
|
8
|
-
import "../worktree-
|
|
9
|
-
import {
|
|
10
|
-
import { A as readClaudeJson, N as writeClaudeJson, S as inputSideTokens, h as poolSettings, i as bundleBuildStamp, j as registerMcpScript, k as mcpEntryRunsScript, v as readPoolState, w as expandMcpNames } from "../pool-
|
|
11
|
-
import { a as unregistrableReason, s as worktreeReason } from "../registrable-
|
|
12
|
-
import "../pai-marker-
|
|
3
|
+
import { _ as paiHomePath, a as paiCacheDir, c as PaiFileMigrationError, d as defaultPaiHomeDir, f as legacyConfigDir, g as paiHomeDir, h as migratePaiFile, l as claudeHomeDir, m as migratePaiDir, o as paiSocketPath, r as daemonLogPath, s as schedulerLogPath, u as claudeProjectsDir, v as resolvePaiFile } from "../runtime-paths-twfV23YL.mjs";
|
|
4
|
+
import { a as slugify$1, i as parseSessionFilename } from "../migrate-C2z1YmWC.mjs";
|
|
5
|
+
import { Et as appendLedger, M as readWorkersSection, R as LOOPBACK_HOST, _t as DEFAULT_CONTEXT_WINDOW, bt as stripModelVariant, c as WorkersConfigError, et as relocateWorkersYaml, f as escalationCloud, m as isLocalProvider, p as getProviderOrNative, rt as workersYamlPath, t as ANTHROPIC_NATIVE, v as nativeAnthropicModels, yt as contextWindowFromModelId } from "../config-Bz8pAU5Q.mjs";
|
|
6
|
+
import { A as DEFAULT_PG_CONNECTION_TIMEOUT_MS, C as migrateMainConfigToYaml, F as quoteIdent, M as isContainerTarget, N as postgresConnectionString, O as readJsonStrict, P as postgresFullTarget, S as MainConfigError, T as resolvedMainConfigPath, c as loadConfig, d as paiConfigYamlFilePath, f as readMainConfigRaw, h as effectiveMainConfig, j as assertPgName, k as writeJsonAtomic, l as migrateConfigFile, n as CONFIG_FILE$2, o as expandHome, p as writeMainConfigRaw, t as CONFIG_DIR, u as paiConfigFilePath } from "../config-SlZ-Z52i.mjs";
|
|
7
|
+
import { i as ledgerPath, n as ensureNoMcpConfig, r as eventsPath, u as workersLogDir } from "../paths-CypxFTfn.mjs";
|
|
8
|
+
import "../worktree-O_kU6Q3y.mjs";
|
|
9
|
+
import { K as writeSectionToForge, R as formatStale, V as loadStatusConfig, W as upsertSection, b as loadStatusesCached, g as isLive, h as isChatPane, v as loadStatus, y as loadStatuses } from "../status-Cecau8Ps.mjs";
|
|
10
|
+
import { A as readClaudeJson, N as writeClaudeJson, S as inputSideTokens, h as poolSettings, i as bundleBuildStamp, j as registerMcpScript, k as mcpEntryRunsScript, v as readPoolState, w as expandMcpNames } from "../pool-9EsbKD-U.mjs";
|
|
11
|
+
import { a as unregistrableReason, s as worktreeReason } from "../registrable-Dxs4oKIj.mjs";
|
|
12
|
+
import "../pai-marker-D6OoVbM_.mjs";
|
|
13
13
|
import "../helpers-CRuBUas3.mjs";
|
|
14
14
|
import { t as applyRecencyBoost } from "../search-RSyeQqj1.mjs";
|
|
15
15
|
import { t as dropItermBundleId } from "../env-BE8mrsxr.mjs";
|
|
16
|
-
import { a as ownerSessionName, d as terminalHasName } from "../scope-
|
|
17
|
-
import { At as
|
|
16
|
+
import { a as ownerSessionName, d as terminalHasName } from "../scope-BTWstJ1B.mjs";
|
|
17
|
+
import { At as resolveBindAddress, Bn as setParkConfigSource, Dn as allSetups, Dt as routerPort, E as budgetCache, Et as routerHandler, F as LimitsCache, Gn as appendUsage, Hn as recordDuration, In as setupsPath, Kn as formatSessionStats, Nn as saveSetup, On as downLocalProviders, Ot as defaultV1Guard, Sn as providerHostPort, U as computeInventory, Un as aggregate, Vn as sharedParker, W as currentInventory, Wn as aggregateBySession, Xn as parseSince, Y as inventoryText, Zn as readUsage, at as readInventoryInput, jn as loadSaved, kt as displayHost, m as readRoutingState, pn as readProfiles, pt as isLoopbackProvider, qn as formatStats, qt as sessionStartTarget, un as SESSION_NAME_ENV, vn as localProviderForDelegation, x as SESSION_WORKER_PROVIDER_ENV, xt as routeVerdictLine, zn as parkConfigFrom } from "../routing-aJKMIagQ.mjs";
|
|
18
18
|
import { n as minOf } from "../min-max-DrSRUaWQ.mjs";
|
|
19
|
-
import "../tool-errors-
|
|
20
|
-
import "../wait-
|
|
21
|
-
import "../local-tiers-
|
|
22
|
-
import { a as buildsDir, c as listBuilds, f as removeTicket, h as tryStart, i as buildNext, o as enqueue, p as renderBuildQueue, r as buildLogPath, s as getTicket, u as patchTicket, v as poolSlots, y as slotsFor } from "../caller-identity-
|
|
19
|
+
import "../tool-errors-BFw1gv-z.mjs";
|
|
20
|
+
import "../wait-C5iWILj_.mjs";
|
|
21
|
+
import "../local-tiers-CqwGUcEn.mjs";
|
|
22
|
+
import { a as buildsDir, c as listBuilds, f as removeTicket, h as tryStart, i as buildNext, o as enqueue, p as renderBuildQueue, r as buildLogPath, s as getTicket, u as patchTicket, v as poolSlots, y as slotsFor } from "../caller-identity-C6Nc96iM.mjs";
|
|
23
23
|
import { d as setSchedConfigSource, f as sharedScheduler, i as mirrorTo, s as schedConfigFrom } from "../scheduler-CtUyWJ3O.mjs";
|
|
24
|
-
import { i as effectiveCap, o as providerCap, r as capacityOf } from "../capacity-
|
|
25
|
-
import "../local-instances-
|
|
26
|
-
import { n as tierProviderNames, t as isLocalCandidate } from "../local-candidate-
|
|
24
|
+
import { i as effectiveCap, o as providerCap, r as capacityOf } from "../capacity-B2PgKAg4.mjs";
|
|
25
|
+
import "../local-instances-DhF4_sV2.mjs";
|
|
26
|
+
import { n as tierProviderNames, t as isLocalCandidate } from "../local-candidate-DFd4pp-w.mjs";
|
|
27
27
|
import { n as OPT, t as CliError } from "../define-CjJsmq-6.mjs";
|
|
28
|
-
import { a as getStorageBackend, c as MergeError, f as humanDuration, i as getRegistryBackend, l as federationDbPath, o as oldRegistryPath, r as createStorageBackend, s as registryDbPath, u as oldFederationPath } from "../factory-
|
|
29
|
-
import { c as resolveFromModule, i as retryTransient, n as embedChunksWithBackend, r as indexProjectWithBackend, s as packageRoot } from "../postgres-
|
|
30
|
-
import { d as OLLAMA_ID, f as ollamaDefaultUrl, i as measureThroughput, l as provisionOllama, o as createBackend, s as detectBackends, u as OLLAMA_DEFAULT_MODEL } from "../embedding-gate-
|
|
28
|
+
import { a as getStorageBackend, c as MergeError, f as humanDuration, i as getRegistryBackend, l as federationDbPath, o as oldRegistryPath, r as createStorageBackend, s as registryDbPath, u as oldFederationPath } from "../factory-CYwa0jYj.mjs";
|
|
29
|
+
import { c as resolveFromModule, i as retryTransient, n as embedChunksWithBackend, r as indexProjectWithBackend, s as packageRoot } from "../postgres-BL5oBBgY.mjs";
|
|
30
|
+
import { d as OLLAMA_ID, f as ollamaDefaultUrl, i as measureThroughput, l as provisionOllama, o as createBackend, s as detectBackends, u as OLLAMA_DEFAULT_MODEL } from "../embedding-gate-iq0oQEkF.mjs";
|
|
31
31
|
import "../embeddings-BVq3esBF.mjs";
|
|
32
|
-
import "../router-
|
|
33
|
-
import { t as detectProject } from "../detect-
|
|
34
|
-
import { n as getProject$1, o as requireProject, r as getProjectAliases, s as resolveIdentifier } from "../helpers-
|
|
35
|
-
import { a as resolveHome, c as upsertSession, d as scanTranscriptFolders, f as transcriptFiles, l as claudeProjectsDir$1, n as loadScanConfig, o as saveScanConfig, r as migrateScanConfig, s as upsertProject, t as cmdScan, u as findMovedProjects } from "../scan-
|
|
36
|
-
import { A as cmdAdd, B as cmdRebind, F as cmdEdit, H as cmdUnarchive, I as cmdGo, L as cmdInfo$1, M as cmdArchive, N as cmdConsolidate, P as cmdDetect, R as cmdList$1, S as legacy, U as findMovedPath, V as cmdTag$1, _ as cmdList, a as newTop, b as cmdSlug, f as sessionCommand, g as cmdInfo, h as cmdAutoRoute, i as lsCommand, j as cmdAlias, l as renameTop, m as cmdActive, r as collectView, s as openTop, v as cmdRename, x as cmdTag, y as cmdRoute, z as cmdMove } from "../session-
|
|
37
|
-
import { a as detectCurrentProviderName, i as detectCurrentModel, n as buildLaunchPlan, o as launchableProviderNames, r as buildLaunchRows, s as maskLaunchEnv } from "../launch-
|
|
38
|
-
import { a as colorEnabled, c as renderCommandHelp, d as renderOverview, f as renderTable$2, l as renderMarkdown, m as usageLine, o as makeStyle, p as suggest, s as mdCommand, u as renderMore } from "../health-
|
|
39
|
-
import { a as providerResumePlan, n as launchInDir, o as restoreTopLevel, r as launchPrompt, s as resumeArgvText, t as hasConversation } from "../launch-
|
|
40
|
-
import { _ as setupsTable, a as lastSetup, m as rootCommand } from "../root-
|
|
41
|
-
import { n as fetchLiveSessions, r as sendToSession, t as callAiBroker } from "../aibroker-client-
|
|
42
|
-
import { a as resolveSessionByNameOrId, o as scanCacheFilePath, r as migrateSessionScanCache, s as scanSessions, t as fmtAge } from "../session-scan-
|
|
43
|
-
import { A as routerPidFile, B as usageContextTokens, D as routerHandoverFile, E as probeRouter, F as startHandover, I as paneWaiver, L as launchPane, M as routerSwapArgs, P as spawnRouter, R as noTerminalReasons, S as usageJson, T as projectLaunchConfig, b as renderUsage, c as reportOriginWithHost, f as dirtyPaths, g as realRunnerDeps, j as routerRunningPid, k as routerLogFile, l as runMessenger, s as finishDetached, u as startDetached, w as pinNotices, x as transcriptUsage, y as priceTableText } from "../run-
|
|
44
|
-
import { C as readRunLog, D as resumeState, E as requestPlanStop, F as runLogPath, I as PlanTracker, M as dirtyConflicts, O as runExitCode, P as loadPlan, S as planStopPath, T as renderStatus, Y as readRunLock, ht as resolveHostSession, j as PlanError,
|
|
45
|
-
import "../endreason-
|
|
46
|
-
import { N as endBuildWait, j as beginBuildWait } from "../render-
|
|
47
|
-
import "../operator-
|
|
48
|
-
import "../kill-tree-
|
|
49
|
-
import { s as poolReloadLive } from "../pool-cli-
|
|
50
|
-
import "../chain-
|
|
51
|
-
import { _ as issueClaims, a as branchDiff, b as workerCommand, c as runQueue, d as loadSettings, f as move, g as issueClaim, h as renderReview, i as projectChecks, l as QueueStore, m as renderList, n as initProject, o as realQueueDeps, p as queueStatePath, r as isGitProject, s as acceptItems, t as formatCheck, u as again, v as issueStale } from "../project-setup-
|
|
52
|
-
import { m as runInPane, r as closeFinishedPanes } from "../pane-audit-
|
|
53
|
-
import "../viewer-
|
|
54
|
-
import "../
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import { n as
|
|
60
|
-
import { a as
|
|
61
|
-
import
|
|
62
|
-
import {
|
|
63
|
-
import { n as queriesDirPath, t as migrateQueriesDir } from "../query-feedback-
|
|
32
|
+
import "../router-D2476pOp.mjs";
|
|
33
|
+
import { t as detectProject } from "../detect-DLjeYwnt.mjs";
|
|
34
|
+
import { n as getProject$1, o as requireProject, r as getProjectAliases, s as resolveIdentifier } from "../helpers-Dj2nY56t.mjs";
|
|
35
|
+
import { a as resolveHome, c as upsertSession, d as scanTranscriptFolders, f as transcriptFiles, l as claudeProjectsDir$1, n as loadScanConfig, o as saveScanConfig, r as migrateScanConfig, s as upsertProject, t as cmdScan, u as findMovedProjects } from "../scan-CNMcltOX.mjs";
|
|
36
|
+
import { A as cmdAdd, B as cmdRebind, F as cmdEdit, H as cmdUnarchive, I as cmdGo, L as cmdInfo$1, M as cmdArchive, N as cmdConsolidate, P as cmdDetect, R as cmdList$1, S as legacy, U as findMovedPath, V as cmdTag$1, _ as cmdList, a as newTop, b as cmdSlug, f as sessionCommand, g as cmdInfo, h as cmdAutoRoute, i as lsCommand, j as cmdAlias, l as renameTop, m as cmdActive, r as collectView, s as openTop, v as cmdRename, x as cmdTag, y as cmdRoute, z as cmdMove } from "../session-HBYOABWU.mjs";
|
|
37
|
+
import { a as detectCurrentProviderName, i as detectCurrentModel, n as buildLaunchPlan, o as launchableProviderNames, r as buildLaunchRows, s as maskLaunchEnv } from "../launch-Bn2Zf90h.mjs";
|
|
38
|
+
import { a as colorEnabled, c as renderCommandHelp, d as renderOverview, f as renderTable$2, l as renderMarkdown, m as usageLine, o as makeStyle, p as suggest, s as mdCommand, u as renderMore } from "../health-D0mUAVdw.mjs";
|
|
39
|
+
import { a as providerResumePlan, n as launchInDir, o as restoreTopLevel, r as launchPrompt, s as resumeArgvText, t as hasConversation } from "../launch-BZT3JDoZ.mjs";
|
|
40
|
+
import { _ as setupsTable, a as lastSetup, m as rootCommand } from "../root-DmOzMeSv.mjs";
|
|
41
|
+
import { n as fetchLiveSessions, r as sendToSession, t as callAiBroker } from "../aibroker-client-CXsyC7A8.mjs";
|
|
42
|
+
import { a as resolveSessionByNameOrId, o as scanCacheFilePath, r as migrateSessionScanCache, s as scanSessions, t as fmtAge } from "../session-scan-CX3MKCNg.mjs";
|
|
43
|
+
import { A as routerPidFile, B as usageContextTokens, D as routerHandoverFile, E as probeRouter, F as startHandover, I as paneWaiver, L as launchPane, M as routerSwapArgs, P as spawnRouter, R as noTerminalReasons, S as usageJson, T as projectLaunchConfig, b as renderUsage, c as reportOriginWithHost, f as dirtyPaths, g as realRunnerDeps, j as routerRunningPid, k as routerLogFile, l as runMessenger, s as finishDetached, u as startDetached, w as pinNotices, x as transcriptUsage, y as priceTableText } from "../run-DE9Q0ldN.mjs";
|
|
44
|
+
import { C as readRunLog, D as resumeState, E as requestPlanStop, F as runLogPath, I as PlanTracker, M as dirtyConflicts, O as runExitCode, Ot as runPaneExec, P as loadPlan, S as planStopPath, T as renderStatus, Y as readRunLock, ht as resolveHostSession, j as PlanError, k as runPlan, l as PlanReporter, nt as closePaneForWorker, ot as inVisiblePane, q as planLockPath, rt as closeSelfPane, u as QueueReporter, vt as writePaneTitle, w as renderSchedule, x as planStatus, y as blockedMessage } from "../controls-rIItFeIJ.mjs";
|
|
45
|
+
import "../endreason-C2-hYK0_.mjs";
|
|
46
|
+
import { B as scanTranscript, H as loadBuildsConfig, L as listRules, N as endBuildWait, P as claudeSessionTranscript, R as loadRules, V as maskSecrets, j as beginBuildWait, q as psTable, z as userRuleDirs } from "../render-jPpMrudf.mjs";
|
|
47
|
+
import "../operator-Br6GeswF.mjs";
|
|
48
|
+
import "../kill-tree-DPiMscaY.mjs";
|
|
49
|
+
import { s as poolReloadLive } from "../pool-cli-Db0uDwB9.mjs";
|
|
50
|
+
import "../chain-CtK49-Ma.mjs";
|
|
51
|
+
import { _ as issueClaims, a as branchDiff, b as workerCommand, c as runQueue, d as loadSettings, f as move, g as issueClaim, h as renderReview, i as projectChecks, l as QueueStore, m as renderList, n as initProject, o as realQueueDeps, p as queueStatePath, r as isGitProject, s as acceptItems, t as formatCheck, u as again, v as issueStale } from "../project-setup-DHxdJjEc.mjs";
|
|
52
|
+
import { m as runInPane, r as closeFinishedPanes } from "../pane-audit-DLYy9-4g.mjs";
|
|
53
|
+
import "../viewer-DvBg6N22.mjs";
|
|
54
|
+
import { g as setWorkersEnabled, s as describeProviders } from "../providers-BNV7CzC-.mjs";
|
|
55
|
+
import { $ as exportDefaultLayout, B as mergeSettings, G as cachedRows, J as handleCockpit, K as cachedSessionMap, Q as warmCockpit, W as SystemMonitor, X as setEndpointHealth, Y as setActionHooks, Z as setRuntimeSampler, ct as providersCommand, et as createEndpointHealth, it as withWorkerAttribution, n as WORKER_CONFIG_COMMANDS, nt as loadWorkerWindows, q as cachedWorkerWindows, rt as sessionDisplayName, t as PROVIDER_EDIT_COMMANDS, tt as loadSessionMap, z as installWorkers } from "../worker-config-DkONNk_H.mjs";
|
|
56
|
+
import { a as listConfigOp, i as getConfigValueOp, n as formatConfigGetOutput, o as setConfigValueOp, r as formatConfigSource, s as unsetConfigValueOp, t as MainConfigOpsError } from "../main-config-ops-rda08BCD.mjs";
|
|
57
|
+
import { H as acquireRun, U as readRun, V as cockpitProbeSettings, k as readRuntimeCommands } from "../cockpit-actions-D1OPP6Es.mjs";
|
|
58
|
+
import { n as PaiClient } from "../ipc-client-CmagIFLh.mjs";
|
|
59
|
+
import { a as EMBED_PASS_BATCH_SIZE, c as liveYieldCheck, n as DEFAULT_EMBED_MAX_LOAD, o as formatEmbedStatus, r as DEFAULT_EMBED_MAX_MINUTES, s as lastRecordedEmbedPass } from "../embed-pass-CN1D8MXl.mjs";
|
|
60
|
+
import { a as liveWorkersConfigShadows, i as totalUsageTokens, r as parseSessionUsage } from "../session-usage-CCmPFFa6.mjs";
|
|
61
|
+
import "../fallback-CwPSCrX8.mjs";
|
|
62
|
+
import { $t as readBodyFile, At as jobPaths, Ct as historyDir, D as migrateWorkQueue, Et as obsidianEnabled, Ft as sessionKeepaliveLedgerSummary, Gt as resolveDaemonPid, Ht as writeQuiet, Lt as describeQuiet, M as migrateSummaryCooldowns, Nt as loadSessionKeepaliveState, Qt as findNotesDir$1, Rt as nextOccurrence, Ut as LAUNCHD_LABEL, Xt as applyContinue, Yt as appendCheckpointToNote, Zt as findLatestNote, c as proposeFromTranscript, en as restartRouter, g as migrateKgBackfillState, h as backfillKgFromNotes, i as getLesson, jt as reapJob, kt as migrateIdentityFile, l as setState, n as LESSON_STATES, o as listLessons, p as reexecBackground, qt as readContextHandoverCache, r as addLesson, s as promoteLesson, t as LESSON_KINDS, wt as OBSIDIAN_DISABLED_MESSAGE, zt as readQuiet } from "../lessons-CFVU0cwA.mjs";
|
|
63
|
+
import { n as queriesDirPath, t as migrateQueriesDir } from "../query-feedback-BWl2HRwN.mjs";
|
|
64
64
|
import { appendFileSync, chmodSync, closeSync, copyFileSync, cpSync, createReadStream, createWriteStream, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
65
65
|
import { createHash, randomUUID } from "node:crypto";
|
|
66
|
-
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
66
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
67
67
|
import chalk from "chalk";
|
|
68
68
|
import { cpus, homedir, platform, userInfo } from "node:os";
|
|
69
69
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -74,8 +74,8 @@ import pg from "pg";
|
|
|
74
74
|
import BetterSqlite3 from "better-sqlite3";
|
|
75
75
|
import { createInterface } from "node:readline";
|
|
76
76
|
import net$1, { createConnection as createConnection$1 } from "net";
|
|
77
|
-
import { promisify } from "node:util";
|
|
78
77
|
import { getEncoding } from "js-tiktoken";
|
|
78
|
+
import { promisify } from "node:util";
|
|
79
79
|
|
|
80
80
|
//#region src/cli/lib/exit.ts
|
|
81
81
|
/**
|
|
@@ -152,7 +152,7 @@ const GLOBAL_FLAGS = [{
|
|
|
152
152
|
type: "boolean",
|
|
153
153
|
summary: ""
|
|
154
154
|
}];
|
|
155
|
-
function parseArgs(cmd, argv) {
|
|
155
|
+
function parseArgs$1(cmd, argv) {
|
|
156
156
|
const defs = [...cmd.options ?? [], ...GLOBAL_FLAGS.filter((g) => !(cmd.options ?? []).some((o) => o.name === g.name))];
|
|
157
157
|
const byLong = new Map(defs.map((o) => [o.name, o]));
|
|
158
158
|
const byShort = new Map(defs.filter((o) => o.short).map((o) => [o.short, o]));
|
|
@@ -326,7 +326,7 @@ async function runCli(groups, argv, io = defaultIo(), root) {
|
|
|
326
326
|
rest: argv
|
|
327
327
|
} : resolve$1(groups, argv);
|
|
328
328
|
const { cmd, path, rest } = resolved;
|
|
329
|
-
const parsed = parseArgs(cmd, rest);
|
|
329
|
+
const parsed = parseArgs$1(cmd, rest);
|
|
330
330
|
const noArgs = rest.length === 0;
|
|
331
331
|
if (parsed.help || noArgs && !cmd.run && cmd.subcommands?.length) {
|
|
332
332
|
if (target) {
|
|
@@ -830,7 +830,7 @@ function routerSamplerDeps(sched, engineStatusTimeoutMs = () => DEFAULT_RUNTIME_
|
|
|
830
830
|
});
|
|
831
831
|
return {
|
|
832
832
|
inventory: async () => {
|
|
833
|
-
const { currentInventory } = await import("../model-inventory-
|
|
833
|
+
const { currentInventory } = await import("../model-inventory-BXv15B0d.mjs");
|
|
834
834
|
return currentInventory();
|
|
835
835
|
},
|
|
836
836
|
sched,
|
|
@@ -845,7 +845,7 @@ function routerSamplerDeps(sched, engineStatusTimeoutMs = () => DEFAULT_RUNTIME_
|
|
|
845
845
|
}
|
|
846
846
|
},
|
|
847
847
|
async rssMb(port) {
|
|
848
|
-
const { processTreeRssMb } = await import("../model-inventory-
|
|
848
|
+
const { processTreeRssMb } = await import("../model-inventory-BXv15B0d.mjs");
|
|
849
849
|
const pid = (await exec("lsof", [
|
|
850
850
|
"-ti",
|
|
851
851
|
`tcp:${port}`,
|
|
@@ -1161,11 +1161,11 @@ const runCommand = {
|
|
|
1161
1161
|
if (!addr) throw new CliError(`cannot resolve router.bind "${bindName}" — use "loopback", "tailscale", or an IPv4 (never 0.0.0.0)`, "pai daemon status");
|
|
1162
1162
|
const remote = addr !== LOOPBACK_HOST;
|
|
1163
1163
|
const extraHosts = (loadConfig().router.allowedHosts ?? []).filter((h) => h.trim());
|
|
1164
|
-
const hosts =
|
|
1165
|
-
bind: addr,
|
|
1164
|
+
const hosts = {
|
|
1165
|
+
bind: bindName || addr,
|
|
1166
1166
|
extraHosts
|
|
1167
|
-
}
|
|
1168
|
-
if (remote &&
|
|
1167
|
+
};
|
|
1168
|
+
if (remote && extraHosts.length === 0) process.stderr.write("pai: router: router.allowedHosts is empty: the cockpit opened by tailnet hostname will be refused (403); add the name to router.allowedHosts\n");
|
|
1169
1169
|
const rows = cachedRows(usageFile);
|
|
1170
1170
|
let schedCache;
|
|
1171
1171
|
setSchedConfigSource(() => {
|
|
@@ -1214,9 +1214,9 @@ const runCommand = {
|
|
|
1214
1214
|
sampler.start();
|
|
1215
1215
|
setRuntimeSampler(sampler);
|
|
1216
1216
|
setActionHooks({ runLocalJob: async (kind, tier) => {
|
|
1217
|
-
const { startTier, stopAll } = await import("../worker-config-
|
|
1218
|
-
const { readLocalConfig } = await import("../local-tiers-
|
|
1219
|
-
const { workersYamlPath } = await import("../workers-config-
|
|
1217
|
+
const { startTier, stopAll } = await import("../worker-config-BmcJ9R0U.mjs");
|
|
1218
|
+
const { readLocalConfig } = await import("../local-tiers-DSlIYvvm.mjs");
|
|
1219
|
+
const { workersYamlPath } = await import("../workers-config-DwzrtdYu.mjs");
|
|
1220
1220
|
const cfg = readLocalConfig(workersYamlPath());
|
|
1221
1221
|
if (kind === "off") return void await stopAll(cfg);
|
|
1222
1222
|
const t = cfg.tiers[tier];
|
|
@@ -1230,10 +1230,8 @@ const runCommand = {
|
|
|
1230
1230
|
setEndpointHealth(endpointHealth);
|
|
1231
1231
|
const serve = (req, res) => handleCockpit(req, res, limits, rows, sessionMap, system, workerWindows);
|
|
1232
1232
|
const inner = routerHandler(void 0, void 0, void 0, usageFile ? () => void 0 : (row) => appendUsage(row), serve, {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
hosts
|
|
1236
|
-
} : {},
|
|
1233
|
+
hosts,
|
|
1234
|
+
...remote ? { remoteAllowed: defaultV1Guard } : {},
|
|
1237
1235
|
isUp: limits.isUp,
|
|
1238
1236
|
health: endpointHealth
|
|
1239
1237
|
});
|
|
@@ -1595,7 +1593,7 @@ const modelsCommand = {
|
|
|
1595
1593
|
if (words[0] === "add") {
|
|
1596
1594
|
const source = words[1];
|
|
1597
1595
|
if (!source) throw new Error("usage: pai models add <hf-id|path> [--name n] [--download] [--try]");
|
|
1598
|
-
const { addModel } = await import("../models-add-
|
|
1596
|
+
const { addModel } = await import("../models-add-5yXTyz3g.mjs");
|
|
1599
1597
|
const r = await addModel({
|
|
1600
1598
|
source,
|
|
1601
1599
|
name: typeof ctx.opts.name === "string" ? ctx.opts.name : void 0,
|
|
@@ -1610,7 +1608,7 @@ const modelsCommand = {
|
|
|
1610
1608
|
if (op) {
|
|
1611
1609
|
const runtime = words[1];
|
|
1612
1610
|
if (!runtime) throw new Error(`usage: pai models ${op} <runtime> (configured: ${Object.keys(readRuntimeCommands()).join(", ") || "none"})`);
|
|
1613
|
-
const { defaultDeps, runtimeStartStop } = await import("../cockpit-actions-
|
|
1611
|
+
const { defaultDeps, runtimeStartStop } = await import("../cockpit-actions-C73mJAJ8.mjs");
|
|
1614
1612
|
const r = await runtimeStartStop(runtime, op, defaultDeps(), { model: typeof ctx.opts.model === "string" ? ctx.opts.model : void 0 });
|
|
1615
1613
|
process.stdout.write(r.message + "\n");
|
|
1616
1614
|
if (!r.ok) process.exitCode = 1;
|
|
@@ -1814,7 +1812,7 @@ const MAX_PROMPT_CHARS = 220;
|
|
|
1814
1812
|
/** Newest prompts kept per transcript — headroom over MAX_PROMPTS for the duplicate collapse. */
|
|
1815
1813
|
const KEEP_PROMPTS = 64;
|
|
1816
1814
|
/** Cap on listed working-tree entries, so a big refactor cannot flood TODO.md. */
|
|
1817
|
-
const MAX_FILES = 20;
|
|
1815
|
+
const MAX_FILES$1 = 20;
|
|
1818
1816
|
/**
|
|
1819
1817
|
* Text of a JSONL message, or null when the entry carries none.
|
|
1820
1818
|
* Mirrors the shape handled by slug-generator: content is either a plain
|
|
@@ -2000,8 +1998,8 @@ function readWorkingTree(cwd) {
|
|
|
2000
1998
|
"-1",
|
|
2001
1999
|
"--pretty=%h %s"
|
|
2002
2000
|
]),
|
|
2003
|
-
changes: all.slice(0, MAX_FILES),
|
|
2004
|
-
overflow: Math.max(0, all.length - MAX_FILES)
|
|
2001
|
+
changes: all.slice(0, MAX_FILES$1),
|
|
2002
|
+
overflow: Math.max(0, all.length - MAX_FILES$1)
|
|
2005
2003
|
};
|
|
2006
2004
|
}
|
|
2007
2005
|
/**
|
|
@@ -2925,7 +2923,7 @@ async function cmdPause(opts) {
|
|
|
2925
2923
|
process.exitCode = 1;
|
|
2926
2924
|
return;
|
|
2927
2925
|
}
|
|
2928
|
-
const { getRegistryBackend } = await import("../factory-
|
|
2926
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
2929
2927
|
const registryBackend = await getRegistryBackend();
|
|
2930
2928
|
const cwd = process.cwd();
|
|
2931
2929
|
const project = await resolveProjectByCwd(registryBackend, cwd);
|
|
@@ -3025,7 +3023,7 @@ function touchSentinel(rootPath) {
|
|
|
3025
3023
|
async function cmdAutosave(opts) {
|
|
3026
3024
|
const minGap = parseInt(opts.minGap ?? String(DEFAULT_MIN_GAP), 10);
|
|
3027
3025
|
const cwd = process.cwd();
|
|
3028
|
-
const { getRegistryBackend } = await import("../factory-
|
|
3026
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
3029
3027
|
const registryBackend = await getRegistryBackend();
|
|
3030
3028
|
const project = await resolveProjectByCwd(registryBackend, cwd);
|
|
3031
3029
|
if (!project) return;
|
|
@@ -3251,7 +3249,7 @@ function finalizeNote(notePath) {
|
|
|
3251
3249
|
}
|
|
3252
3250
|
async function cmdEnd(opts) {
|
|
3253
3251
|
await cmdPause(opts);
|
|
3254
|
-
const { getRegistryBackend } = await import("../factory-
|
|
3252
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
3255
3253
|
const registryBackend = await getRegistryBackend();
|
|
3256
3254
|
const cwd = process.cwd();
|
|
3257
3255
|
const project = await registryBackend.findProjectByCwdPrefix(cwd);
|
|
@@ -3432,7 +3430,7 @@ function buildStopBody(project, sessionId) {
|
|
|
3432
3430
|
}
|
|
3433
3431
|
}
|
|
3434
3432
|
async function cmdHandover(projectSlug, numberOrLatest, sessionId) {
|
|
3435
|
-
const { getRegistryBackend } = await import("../factory-
|
|
3433
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
3436
3434
|
const registryBackend = await getRegistryBackend();
|
|
3437
3435
|
let project;
|
|
3438
3436
|
if (projectSlug) {
|
|
@@ -4378,8 +4376,8 @@ async function analyzeProject(registryBackend, project) {
|
|
|
4378
4376
|
async function countVectorDbPaths(oldPaths) {
|
|
4379
4377
|
if (oldPaths.length === 0) return 0;
|
|
4380
4378
|
try {
|
|
4381
|
-
const { loadConfig } = await import("../config-
|
|
4382
|
-
const { createStorageBackend } = await import("../factory-
|
|
4379
|
+
const { loadConfig } = await import("../config-DHgPx3ER.mjs");
|
|
4380
|
+
const { createStorageBackend } = await import("../factory-DFwez99M.mjs");
|
|
4383
4381
|
const config = loadConfig();
|
|
4384
4382
|
if (config.storageBackend !== "postgres") return 0;
|
|
4385
4383
|
const backend = await createStorageBackend(config);
|
|
@@ -4396,8 +4394,8 @@ async function countVectorDbPaths(oldPaths) {
|
|
|
4396
4394
|
async function updateVectorDbPaths(moves) {
|
|
4397
4395
|
if (moves.length === 0) return 0;
|
|
4398
4396
|
try {
|
|
4399
|
-
const { loadConfig } = await import("../config-
|
|
4400
|
-
const { createStorageBackend } = await import("../factory-
|
|
4397
|
+
const { loadConfig } = await import("../config-DHgPx3ER.mjs");
|
|
4398
|
+
const { createStorageBackend } = await import("../factory-DFwez99M.mjs");
|
|
4401
4399
|
const config = loadConfig();
|
|
4402
4400
|
if (config.storageBackend !== "postgres") return 0;
|
|
4403
4401
|
const backend = await createStorageBackend(config);
|
|
@@ -4484,7 +4482,7 @@ async function displayDryRun(plans) {
|
|
|
4484
4482
|
console.log();
|
|
4485
4483
|
}
|
|
4486
4484
|
async function executeCleanup(plans, skipReindex) {
|
|
4487
|
-
const { getRegistryBackend } = await import("../factory-
|
|
4485
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
4488
4486
|
const registryBackend = await getRegistryBackend();
|
|
4489
4487
|
let deleted = 0;
|
|
4490
4488
|
let renamed = 0;
|
|
@@ -4684,7 +4682,7 @@ async function executeCleanup(plans, skipReindex) {
|
|
|
4684
4682
|
* them into YYYY/MM/ folders and renumber. Dry run unless `execute` is set.
|
|
4685
4683
|
*/
|
|
4686
4684
|
async function runCleanup(projectSlug, opts) {
|
|
4687
|
-
const { getRegistryBackend } = await import("../factory-
|
|
4685
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
4688
4686
|
const registryBackend = await getRegistryBackend();
|
|
4689
4687
|
const dryRun = !opts.execute;
|
|
4690
4688
|
const skipReindex = opts.reindex === false;
|
|
@@ -6666,7 +6664,7 @@ async function runRestore(backupPath, opts) {
|
|
|
6666
6664
|
* backup is written first. Any other content, or an unknown previous rendering,
|
|
6667
6665
|
* counts as user-edited: the new version goes beside it as CLAUDE.md.pai-new.
|
|
6668
6666
|
*/
|
|
6669
|
-
function stamp(d) {
|
|
6667
|
+
function stamp$1(d) {
|
|
6670
6668
|
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
6671
6669
|
}
|
|
6672
6670
|
function writeAtomic(path, content) {
|
|
@@ -6685,7 +6683,7 @@ function refreshClaudeMd(claudeMd, newRendered, previousRendered, now = /* @__PU
|
|
|
6685
6683
|
newPath
|
|
6686
6684
|
};
|
|
6687
6685
|
}
|
|
6688
|
-
const backup = `${claudeMd}.bak-${stamp(now)}`;
|
|
6686
|
+
const backup = `${claudeMd}.bak-${stamp$1(now)}`;
|
|
6689
6687
|
copyFileSync(claudeMd, backup);
|
|
6690
6688
|
writeAtomic(claudeMd, newRendered);
|
|
6691
6689
|
return {
|
|
@@ -8260,6 +8258,10 @@ const SETTINGS_HOOKS = [
|
|
|
8260
8258
|
hookType: "UserPromptSubmit",
|
|
8261
8259
|
command: "${ADAPTER_DIR}/Hooks/local-model-hint.mjs"
|
|
8262
8260
|
},
|
|
8261
|
+
{
|
|
8262
|
+
hookType: "UserPromptSubmit",
|
|
8263
|
+
command: "${ADAPTER_DIR}/Hooks/handoff-link.mjs"
|
|
8264
|
+
},
|
|
8263
8265
|
{
|
|
8264
8266
|
hookType: "UserPromptSubmit",
|
|
8265
8267
|
command: "${ADAPTER_DIR}/Hooks/capture-all-events.mjs --event-type UserPromptSubmit"
|
|
@@ -8967,8 +8969,8 @@ function cmdLogs(opts) {
|
|
|
8967
8969
|
return 0;
|
|
8968
8970
|
}
|
|
8969
8971
|
async function daemonServeCmd() {
|
|
8970
|
-
const { serve } = await import("../daemon-
|
|
8971
|
-
const { loadConfig: lc, ensureConfigDir } = await import("../config-
|
|
8972
|
+
const { serve } = await import("../daemon-176qA79N.mjs");
|
|
8973
|
+
const { loadConfig: lc, ensureConfigDir } = await import("../config-DHgPx3ER.mjs");
|
|
8972
8974
|
ensureConfigDir();
|
|
8973
8975
|
await serve(lc());
|
|
8974
8976
|
return 0;
|
|
@@ -11139,6 +11141,232 @@ async function watchTicket(dir, id, deps = {}, intervalMs = 1e3, simctl, reap) {
|
|
|
11139
11141
|
}
|
|
11140
11142
|
}
|
|
11141
11143
|
|
|
11144
|
+
//#endregion
|
|
11145
|
+
//#region src/audit/tokens.ts
|
|
11146
|
+
/**
|
|
11147
|
+
* tokens.ts — the one tokenizer every audit section counts through.
|
|
11148
|
+
*
|
|
11149
|
+
* cl100k_base via js-tiktoken (pure JS, no native build). "all" + [] for the
|
|
11150
|
+
* allowed/disallowed-special-token arguments matches Python tiktoken's
|
|
11151
|
+
* `disallowed_special=()`: audited text (hook stdout, session transcripts)
|
|
11152
|
+
* routinely contains literal "<|...|>"-shaped substrings that are not actual
|
|
11153
|
+
* special tokens, and the strict default throws on sight of them.
|
|
11154
|
+
*/
|
|
11155
|
+
const TOKEN_ENCODING = "cl100k_base";
|
|
11156
|
+
let cached = null;
|
|
11157
|
+
function encoder() {
|
|
11158
|
+
if (!cached) cached = getEncoding(TOKEN_ENCODING);
|
|
11159
|
+
return cached;
|
|
11160
|
+
}
|
|
11161
|
+
function countTokens(text) {
|
|
11162
|
+
if (!text) return 0;
|
|
11163
|
+
return encoder().encode(text, "all", []).length;
|
|
11164
|
+
}
|
|
11165
|
+
|
|
11166
|
+
//#endregion
|
|
11167
|
+
//#region src/audit/hooks.ts
|
|
11168
|
+
/**
|
|
11169
|
+
* `pai audit tokens hooks` — token cost of the hooks that fire before the
|
|
11170
|
+
* model ever sees a token: SessionStart and UserPromptSubmit run on every
|
|
11171
|
+
* new session / every prompt respectively, and whatever they print to
|
|
11172
|
+
* stdout is injected into context.
|
|
11173
|
+
*/
|
|
11174
|
+
const HOOK_TIMEOUT_MS = 2e4;
|
|
11175
|
+
function settingsPaths(cwd) {
|
|
11176
|
+
return [join(claudeHomeDir(), "settings.json"), join(cwd, ".claude", "settings.json")].filter(existsSync);
|
|
11177
|
+
}
|
|
11178
|
+
function readSettings(path) {
|
|
11179
|
+
try {
|
|
11180
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
11181
|
+
} catch {
|
|
11182
|
+
return {};
|
|
11183
|
+
}
|
|
11184
|
+
}
|
|
11185
|
+
/** Expand ${PAI_DIR} and $HOME/${HOME} the way settings.json's own hooks do. */
|
|
11186
|
+
function expandCommand(command, env) {
|
|
11187
|
+
const paiDir = env.PAI_DIR ?? process.env.PAI_DIR ?? claudeHomeDir();
|
|
11188
|
+
return command.replace(/\$\{PAI_DIR\}/g, paiDir).replace(/\$\{HOME\}/g, homedir()).replace(/\$HOME\b/g, homedir());
|
|
11189
|
+
}
|
|
11190
|
+
function syntheticStdin(cwd, eventName) {
|
|
11191
|
+
const sessionId = randomUUID();
|
|
11192
|
+
return JSON.stringify({
|
|
11193
|
+
session_id: sessionId,
|
|
11194
|
+
cwd,
|
|
11195
|
+
hook_event_name: eventName,
|
|
11196
|
+
prompt: "audit",
|
|
11197
|
+
transcript_path: join(claudeProjectsDir(), "audit", `${sessionId}.jsonl`)
|
|
11198
|
+
});
|
|
11199
|
+
}
|
|
11200
|
+
function runHook(command, cwd, eventName) {
|
|
11201
|
+
const result = spawnSync(command, {
|
|
11202
|
+
shell: true,
|
|
11203
|
+
cwd,
|
|
11204
|
+
input: syntheticStdin(cwd, eventName),
|
|
11205
|
+
encoding: "utf8",
|
|
11206
|
+
timeout: HOOK_TIMEOUT_MS
|
|
11207
|
+
});
|
|
11208
|
+
if (result.error) return {
|
|
11209
|
+
stdout: "",
|
|
11210
|
+
error: result.error.message
|
|
11211
|
+
};
|
|
11212
|
+
if (result.signal) return {
|
|
11213
|
+
stdout: result.stdout ?? "",
|
|
11214
|
+
error: `killed by ${result.signal} (timeout?)`
|
|
11215
|
+
};
|
|
11216
|
+
return { stdout: result.stdout ?? "" };
|
|
11217
|
+
}
|
|
11218
|
+
const NOT_RUN = "not run (pass --run to execute hooks)";
|
|
11219
|
+
const AUDITED_EVENTS = ["SessionStart", "UserPromptSubmit"];
|
|
11220
|
+
/** Hook events the capture hook records, so a configured hook can be checked
|
|
11221
|
+
* against what actually fired. */
|
|
11222
|
+
const OBSERVABLE_EVENTS = [
|
|
11223
|
+
"PreToolUse",
|
|
11224
|
+
"PostToolUse",
|
|
11225
|
+
"UserPromptSubmit",
|
|
11226
|
+
"Stop",
|
|
11227
|
+
"SubagentStop",
|
|
11228
|
+
"SessionStart",
|
|
11229
|
+
"PreCompact",
|
|
11230
|
+
"Notification"
|
|
11231
|
+
];
|
|
11232
|
+
/** Default observation window for `--since`. */
|
|
11233
|
+
const DEFAULT_SINCE_HOURS = 24;
|
|
11234
|
+
/** Where capture-all-events writes its JSONL: PAI_HOME/History/raw-outputs. */
|
|
11235
|
+
function defaultCaptureDir() {
|
|
11236
|
+
return join(paiHomePath("History"), "raw-outputs");
|
|
11237
|
+
}
|
|
11238
|
+
/**
|
|
11239
|
+
* Counts hook events in the raw-capture JSONL files modified since `sinceMs`
|
|
11240
|
+
* (capture-all-events lines carry `hook_event_type` and `timestamp`). Files
|
|
11241
|
+
* are read line by line; malformed lines are skipped.
|
|
11242
|
+
*/
|
|
11243
|
+
async function observedHookEvents(captureDir, sinceMs) {
|
|
11244
|
+
const observed = /* @__PURE__ */ new Map();
|
|
11245
|
+
if (!existsSync(captureDir)) return observed;
|
|
11246
|
+
const monthDirs = readdirSync(captureDir, { withFileTypes: true }).filter((d) => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)).map((d) => join(captureDir, d.name));
|
|
11247
|
+
for (const monthDir of monthDirs) for (const name of readdirSync(monthDir)) {
|
|
11248
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
11249
|
+
const file = join(monthDir, name);
|
|
11250
|
+
try {
|
|
11251
|
+
if (statSync(file).mtimeMs < sinceMs) continue;
|
|
11252
|
+
} catch {
|
|
11253
|
+
continue;
|
|
11254
|
+
}
|
|
11255
|
+
const rl = createInterface({
|
|
11256
|
+
input: createReadStream(file, "utf8"),
|
|
11257
|
+
crlfDelay: Infinity
|
|
11258
|
+
});
|
|
11259
|
+
for await (const raw of rl) foldObservedLine(raw, observed);
|
|
11260
|
+
}
|
|
11261
|
+
return observed;
|
|
11262
|
+
}
|
|
11263
|
+
/** True when the command references a file path that does not exist (a hook
|
|
11264
|
+
* script that was moved or deleted — the hook silently never runs). */
|
|
11265
|
+
function commandHasMissingFile(command) {
|
|
11266
|
+
for (const token of command.split(/\s+/)) {
|
|
11267
|
+
if (!token.includes("/")) continue;
|
|
11268
|
+
if (token.includes("$") || token.includes("*")) continue;
|
|
11269
|
+
if (!existsSync(token)) return true;
|
|
11270
|
+
}
|
|
11271
|
+
return false;
|
|
11272
|
+
}
|
|
11273
|
+
function auditHooks(cwd, run, maybeOpts) {
|
|
11274
|
+
const opts = typeof run === "function" ? maybeOpts ?? {} : run ?? {};
|
|
11275
|
+
const runner = typeof run === "function" ? run : void 0;
|
|
11276
|
+
const sinceHours = opts.sinceHours ?? DEFAULT_SINCE_HOURS;
|
|
11277
|
+
const now = opts.now ?? Date.now();
|
|
11278
|
+
const observed = observedHookEventsSync(opts.captureDir ?? defaultCaptureDir(), now - sinceHours * 36e5);
|
|
11279
|
+
const settingsFiles = settingsPaths(cwd);
|
|
11280
|
+
const readings = [];
|
|
11281
|
+
const totalsByEvent = {};
|
|
11282
|
+
const preToolUseHooks = [];
|
|
11283
|
+
let preToolUseRewritesBash = false;
|
|
11284
|
+
for (const settingsPath of settingsFiles) {
|
|
11285
|
+
const settings = readSettings(settingsPath);
|
|
11286
|
+
const env = settings.env ?? {};
|
|
11287
|
+
for (const eventName of AUDITED_EVENTS) {
|
|
11288
|
+
const groups = settings.hooks?.[eventName] ?? [];
|
|
11289
|
+
totalsByEvent[eventName] = totalsByEvent[eventName] ?? 0;
|
|
11290
|
+
for (const group of groups) for (const entry of group.hooks ?? []) {
|
|
11291
|
+
if (!entry.command) continue;
|
|
11292
|
+
const command = expandCommand(entry.command, env);
|
|
11293
|
+
const { stdout, error } = runner ? runner(command, cwd, eventName) : {
|
|
11294
|
+
stdout: "",
|
|
11295
|
+
error: NOT_RUN
|
|
11296
|
+
};
|
|
11297
|
+
const tokens = countTokens(stdout);
|
|
11298
|
+
totalsByEvent[eventName] += tokens;
|
|
11299
|
+
const seen = observed.get(eventName);
|
|
11300
|
+
readings.push({
|
|
11301
|
+
event: eventName,
|
|
11302
|
+
matcher: group.matcher,
|
|
11303
|
+
command,
|
|
11304
|
+
tokens,
|
|
11305
|
+
error,
|
|
11306
|
+
state: commandHasMissingFile(command) ? "missing-script" : seen ? "observed" : "configured",
|
|
11307
|
+
lastSeen: seen?.last
|
|
11308
|
+
});
|
|
11309
|
+
}
|
|
11310
|
+
}
|
|
11311
|
+
for (const group of settings.hooks?.PreToolUse ?? []) for (const entry of group.hooks ?? []) {
|
|
11312
|
+
if (!entry.command) continue;
|
|
11313
|
+
preToolUseHooks.push({
|
|
11314
|
+
matcher: group.matcher,
|
|
11315
|
+
command: expandCommand(entry.command, env)
|
|
11316
|
+
});
|
|
11317
|
+
if (group.matcher === "Bash") preToolUseRewritesBash = true;
|
|
11318
|
+
}
|
|
11319
|
+
}
|
|
11320
|
+
return {
|
|
11321
|
+
encoding: TOKEN_ENCODING,
|
|
11322
|
+
settingsFiles,
|
|
11323
|
+
readings,
|
|
11324
|
+
totalsByEvent,
|
|
11325
|
+
preToolUseHooks,
|
|
11326
|
+
preToolUseRewritesBash
|
|
11327
|
+
};
|
|
11328
|
+
}
|
|
11329
|
+
/** Sync variant used by auditHooks; same folding as observedHookEvents. */
|
|
11330
|
+
function observedHookEventsSync(captureDir, sinceMs) {
|
|
11331
|
+
const observed = /* @__PURE__ */ new Map();
|
|
11332
|
+
if (!existsSync(captureDir)) return observed;
|
|
11333
|
+
const monthDirs = readdirSync(captureDir, { withFileTypes: true }).filter((d) => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)).map((d) => join(captureDir, d.name));
|
|
11334
|
+
for (const monthDir of monthDirs) for (const name of readdirSync(monthDir)) {
|
|
11335
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
11336
|
+
const file = join(monthDir, name);
|
|
11337
|
+
let text;
|
|
11338
|
+
try {
|
|
11339
|
+
if (statSync(file).mtimeMs < sinceMs) continue;
|
|
11340
|
+
text = readFileSync(file, "utf8");
|
|
11341
|
+
} catch {
|
|
11342
|
+
continue;
|
|
11343
|
+
}
|
|
11344
|
+
for (const raw of text.split("\n")) foldObservedLine(raw, observed);
|
|
11345
|
+
}
|
|
11346
|
+
return observed;
|
|
11347
|
+
}
|
|
11348
|
+
function foldObservedLine(raw, observed) {
|
|
11349
|
+
if (!raw.includes("hook_event_type")) return;
|
|
11350
|
+
let parsed;
|
|
11351
|
+
try {
|
|
11352
|
+
parsed = JSON.parse(raw);
|
|
11353
|
+
} catch {
|
|
11354
|
+
return;
|
|
11355
|
+
}
|
|
11356
|
+
const event = parsed.hook_event_type;
|
|
11357
|
+
if (!event) return;
|
|
11358
|
+
const ts = typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now();
|
|
11359
|
+
const last = new Date(ts).toISOString();
|
|
11360
|
+
const entry = observed.get(event);
|
|
11361
|
+
if (entry) {
|
|
11362
|
+
entry.count += 1;
|
|
11363
|
+
if (last > entry.last) entry.last = last;
|
|
11364
|
+
} else observed.set(event, {
|
|
11365
|
+
count: 1,
|
|
11366
|
+
last
|
|
11367
|
+
});
|
|
11368
|
+
}
|
|
11369
|
+
|
|
11142
11370
|
//#endregion
|
|
11143
11371
|
//#region src/builds/doctor.ts
|
|
11144
11372
|
/**
|
|
@@ -11175,6 +11403,33 @@ function doctor(cwd) {
|
|
|
11175
11403
|
findings
|
|
11176
11404
|
};
|
|
11177
11405
|
}
|
|
11406
|
+
/**
|
|
11407
|
+
* One line per hook event: whether it is configured in settings.json and
|
|
11408
|
+
* whether a capture proves it actually ran in the last `sinceHours`. A hook
|
|
11409
|
+
* that is configured but never observed is the classic silent-failure case.
|
|
11410
|
+
*/
|
|
11411
|
+
async function hookEventFindings(cwd, opts = {}) {
|
|
11412
|
+
const sinceHours = opts.sinceHours ?? DEFAULT_SINCE_HOURS;
|
|
11413
|
+
const now = opts.now ?? Date.now();
|
|
11414
|
+
const report = auditHooks(cwd, {
|
|
11415
|
+
sinceHours,
|
|
11416
|
+
captureDir: opts.captureDir,
|
|
11417
|
+
now
|
|
11418
|
+
});
|
|
11419
|
+
const observed = await observedHookEvents(opts.captureDir ?? defaultCaptureDir(), now - sinceHours * 36e5);
|
|
11420
|
+
const configuredEvents = new Set(report.readings.map((r) => r.event));
|
|
11421
|
+
const findings = [];
|
|
11422
|
+
for (const event of OBSERVABLE_EVENTS) {
|
|
11423
|
+
const seen = observed.get(event);
|
|
11424
|
+
findings.push({
|
|
11425
|
+
event,
|
|
11426
|
+
configured: configuredEvents.has(event),
|
|
11427
|
+
observed: !!seen,
|
|
11428
|
+
lastSeen: seen?.last
|
|
11429
|
+
});
|
|
11430
|
+
}
|
|
11431
|
+
return findings;
|
|
11432
|
+
}
|
|
11178
11433
|
|
|
11179
11434
|
//#endregion
|
|
11180
11435
|
//#region src/cli/commands/build.ts
|
|
@@ -11315,14 +11570,19 @@ const nextCmd = {
|
|
|
11315
11570
|
};
|
|
11316
11571
|
const doctorCmd = {
|
|
11317
11572
|
name: "doctor",
|
|
11318
|
-
summary: "Check the project's registered build doors",
|
|
11319
|
-
description: "Reads .pai/builds.yaml above the current directory and flags every door that is missing or never calls `pai build lock`, which would bypass the queue.",
|
|
11573
|
+
summary: "Check the project's registered build doors and hook events",
|
|
11574
|
+
description: "Reads .pai/builds.yaml above the current directory and flags every door that is missing or never calls `pai build lock`, which would bypass the queue. Also prints one line per hook event: observed (a capture proves it ran) or configured-but-never-observed (warning — the hook may silently not be running).",
|
|
11320
11575
|
examples: ["pai build doctor"],
|
|
11321
|
-
run: (ctx) => {
|
|
11576
|
+
run: async (ctx) => {
|
|
11322
11577
|
const r = doctor(process.cwd());
|
|
11323
|
-
if (!r.root)
|
|
11324
|
-
|
|
11325
|
-
|
|
11578
|
+
if (!r.root) out$2("no .pai/builds.yaml here: no doors registered");
|
|
11579
|
+
else {
|
|
11580
|
+
for (const f of r.findings) out$2(`${f.ok ? "ok " : "FAIL"} ${f.door}${f.problem ? ` — ${f.problem}` : ""}`);
|
|
11581
|
+
if (!r.findings.length) out$2("no doors registered");
|
|
11582
|
+
}
|
|
11583
|
+
const sinceHours = 24;
|
|
11584
|
+
for (const h of await hookEventFindings(process.cwd(), { sinceHours })) if (h.observed) out$2(`ok ${h.event}: observed ${h.lastSeen ?? ""}`.trimEnd());
|
|
11585
|
+
else if (h.configured) out$2(`WARN ${h.event}: configured, never observed in ${sinceHours}h`);
|
|
11326
11586
|
return r.findings.every((f) => f.ok) ? 0 : 1;
|
|
11327
11587
|
}
|
|
11328
11588
|
};
|
|
@@ -11999,8 +12259,8 @@ async function escalate(task, headline, detail, state, now, opts) {
|
|
|
11999
12259
|
state.alarmedAt[task.id] = now;
|
|
12000
12260
|
let delivered = false;
|
|
12001
12261
|
try {
|
|
12002
|
-
const { routeNotification } = await import("../router-
|
|
12003
|
-
const { loadConfig } = await import("../config-
|
|
12262
|
+
const { routeNotification } = await import("../router-ghujlEFb.mjs");
|
|
12263
|
+
const { loadConfig } = await import("../config-DHgPx3ER.mjs");
|
|
12004
12264
|
delivered = (await routeNotification({
|
|
12005
12265
|
event: "error",
|
|
12006
12266
|
title: `PAI: ${headline}`,
|
|
@@ -13916,6 +14176,16 @@ function configListCmd(opts) {
|
|
|
13916
14176
|
console.log(opts.json ? JSON.stringify(r.data, null, 2) : r.yaml.trimEnd());
|
|
13917
14177
|
return 0;
|
|
13918
14178
|
}
|
|
14179
|
+
/** `pai config validate`: shadowed and unused workers keys across config.yaml and workers.yaml (warnings; exit 0). */
|
|
14180
|
+
function configValidateCmd() {
|
|
14181
|
+
const shadows = liveWorkersConfigShadows();
|
|
14182
|
+
if (!shadows.length) {
|
|
14183
|
+
console.log(ok$1("config.yaml and workers.yaml: no shadowed or unused keys"));
|
|
14184
|
+
return 0;
|
|
14185
|
+
}
|
|
14186
|
+
for (const s of shadows) console.log(`warning: ${s.message}`);
|
|
14187
|
+
return 0;
|
|
14188
|
+
}
|
|
13919
14189
|
function configGetCmd(path, opts) {
|
|
13920
14190
|
try {
|
|
13921
14191
|
const r = getConfigValueOp(path);
|
|
@@ -14192,6 +14462,14 @@ const configSubcommands = [
|
|
|
14192
14462
|
seeAlso: ["pai config migrate", "pai config set"],
|
|
14193
14463
|
run: (ctx) => configUnsetCmd(String(ctx.args.PATH))
|
|
14194
14464
|
},
|
|
14465
|
+
{
|
|
14466
|
+
name: "validate",
|
|
14467
|
+
summary: "Warn about workers settings that are shadowed or unused",
|
|
14468
|
+
description: "Warn about workers settings that are shadowed or unused.\n\nThe non-provider workers.* settings (pool, pane, routing, …) are read from config.yaml only; the routing tables (providers, tiers, classes, mcp_sets, …) from workers.yaml only. Names every key written in the file that does not win, and every top-level workers.yaml block nothing reads.",
|
|
14469
|
+
examples: ["pai config validate", "pai config get workers.pool.maxSlots --source"],
|
|
14470
|
+
seeAlso: ["pai config get", "pai config set"],
|
|
14471
|
+
run: () => configValidateCmd()
|
|
14472
|
+
},
|
|
14195
14473
|
{
|
|
14196
14474
|
name: "migrate",
|
|
14197
14475
|
summary: "Move old settings files into the current layout",
|
|
@@ -14924,7 +15202,7 @@ async function migrateToPostgres(opts = {}) {
|
|
|
14924
15202
|
const dump = pgDump(container, pgConfig, dumpPath);
|
|
14925
15203
|
if (!dump.ok) return refused(dump.reason, dryRun);
|
|
14926
15204
|
}
|
|
14927
|
-
const { PostgresBackend } = await import("../postgres-
|
|
15205
|
+
const { PostgresBackend } = await import("../postgres-B1B80bcj.mjs");
|
|
14928
15206
|
await PostgresBackend.ensureDatabase(pgConfig);
|
|
14929
15207
|
const sqlite = new BetterSqlite3(sqliteFederationPath, { readonly: true });
|
|
14930
15208
|
const registrySqlite = sqliteRegistryPath === sqliteFederationPath ? sqlite : new BetterSqlite3(sqliteRegistryPath, { readonly: true });
|
|
@@ -16311,7 +16589,7 @@ const out = (s) => process.stdout.write(s + "\n");
|
|
|
16311
16589
|
async function repoOf(project) {
|
|
16312
16590
|
if (typeof project === "string" && project) {
|
|
16313
16591
|
if (existsSync(project)) return resolve(project);
|
|
16314
|
-
const { getProject } = await import("../helpers-
|
|
16592
|
+
const { getProject } = await import("../helpers-DyHKAwrW.mjs");
|
|
16315
16593
|
const row = await getProject(project);
|
|
16316
16594
|
if (!row) throw new CliError(`no project ${project}`, "pai projects list");
|
|
16317
16595
|
return row.root_path;
|
|
@@ -16540,7 +16818,7 @@ const reviewCmd = {
|
|
|
16540
16818
|
return 0;
|
|
16541
16819
|
}
|
|
16542
16820
|
};
|
|
16543
|
-
const listCmd = {
|
|
16821
|
+
const listCmd$2 = {
|
|
16544
16822
|
name: "list",
|
|
16545
16823
|
summary: "Every item of the project with its state",
|
|
16546
16824
|
description: "States: pending, running, ready, too-big, needs-decision, cloud, accepted, rejected.",
|
|
@@ -16634,7 +16912,7 @@ const queueCommand = {
|
|
|
16634
16912
|
addCmd,
|
|
16635
16913
|
runCmd,
|
|
16636
16914
|
reviewCmd,
|
|
16637
|
-
listCmd,
|
|
16915
|
+
listCmd$2,
|
|
16638
16916
|
acceptCmd,
|
|
16639
16917
|
rejectCmd,
|
|
16640
16918
|
againCmd
|
|
@@ -17134,7 +17412,7 @@ async function cmdPromote(opts) {
|
|
|
17134
17412
|
console.error(dim$1(` Pick a durable location for the new project.`));
|
|
17135
17413
|
process.exit(1);
|
|
17136
17414
|
}
|
|
17137
|
-
const { getRegistryBackend } = await import("../factory-
|
|
17415
|
+
const { getRegistryBackend } = await import("../factory-DFwez99M.mjs");
|
|
17138
17416
|
const registryBackend = await getRegistryBackend();
|
|
17139
17417
|
const encodedDir = encodeDir(targetPath);
|
|
17140
17418
|
if (await registryBackend.getProjectBySlug(slug) ?? await registryBackend.getProjectByRootPath(targetPath) ?? await registryBackend.getProjectByEncodedDir(encodedDir)) {
|
|
@@ -18378,7 +18656,7 @@ async function cmdUnregister(slug, opts = {}) {
|
|
|
18378
18656
|
* deprecated alias that forwards here with a stderr notice.
|
|
18379
18657
|
*/
|
|
18380
18658
|
async function projectListCmd(opts) {
|
|
18381
|
-
const { cmdMain } = await import("../main-resolver-
|
|
18659
|
+
const { cmdMain } = await import("../main-resolver-BKwI0I6H.mjs");
|
|
18382
18660
|
await cmdMain(void 0, void 0, opts);
|
|
18383
18661
|
return 0;
|
|
18384
18662
|
}
|
|
@@ -19801,7 +20079,7 @@ async function registryScanCmd(opts) {
|
|
|
19801
20079
|
return 0;
|
|
19802
20080
|
}
|
|
19803
20081
|
if (opts.filterReport) {
|
|
19804
|
-
const { reportUnregistrable } = await import("../registrable-
|
|
20082
|
+
const { reportUnregistrable } = await import("../registrable-BwFiqazy.mjs");
|
|
19805
20083
|
const r = await reportUnregistrable(await getRegistryBackend());
|
|
19806
20084
|
console.log(`Registry rows: ${r.total}`);
|
|
19807
20085
|
console.log(` matching the registration filter: ${r.matching} (${r.matchingMissing} with a missing path)`);
|
|
@@ -21644,7 +21922,7 @@ async function runEmbed(backend, projectId, projectSlug, batchSize = 50, pageSiz
|
|
|
21644
21922
|
const label = projectSlug ? `project ${projectSlug}` : "all projects";
|
|
21645
21923
|
console.log(dim$1(`Generating embeddings for ${label} (this may take a while on first run)...`));
|
|
21646
21924
|
const { serializeEmbedding } = await import("../embeddings-CIkJBKd-.mjs");
|
|
21647
|
-
const { gateBackend } = await import("../embedding-gate-
|
|
21925
|
+
const { gateBackend } = await import("../embedding-gate-BVQvUmox.mjs");
|
|
21648
21926
|
const gate = await gateBackend(backend, { record: true });
|
|
21649
21927
|
if (!gate.ok) {
|
|
21650
21928
|
console.error(err(gate.reason));
|
|
@@ -21973,7 +22251,7 @@ async function memorySearchCmd(query, opts) {
|
|
|
21973
22251
|
let results;
|
|
21974
22252
|
if (mode === "keyword") results = await backend.searchKeyword(query, searchOpts);
|
|
21975
22253
|
else {
|
|
21976
|
-
const { embedQuery } = await import("../embedding-gate-
|
|
22254
|
+
const { embedQuery } = await import("../embedding-gate-BVQvUmox.mjs");
|
|
21977
22255
|
console.log(dim$1("Generating query embedding..."));
|
|
21978
22256
|
const q = await embedQuery(backend, query);
|
|
21979
22257
|
const queryEmbedding = q.vec;
|
|
@@ -22053,8 +22331,8 @@ async function memorySearchCmd(query, opts) {
|
|
|
22053
22331
|
//#region src/cli/commands/memory/sources-cmd.ts
|
|
22054
22332
|
/** `pai memory sources` — what the indexer has taken in. */
|
|
22055
22333
|
async function memorySourcesCmd(opts) {
|
|
22056
|
-
const { createStorageBackend } = await import("../factory-
|
|
22057
|
-
const { loadConfig } = await import("../config-
|
|
22334
|
+
const { createStorageBackend } = await import("../factory-DFwez99M.mjs");
|
|
22335
|
+
const { loadConfig } = await import("../config-DHgPx3ER.mjs");
|
|
22058
22336
|
const { cmdMemorySources } = await import("../sources-CiE3rZek.mjs");
|
|
22059
22337
|
let backend;
|
|
22060
22338
|
try {
|
|
@@ -22814,7 +23092,7 @@ async function zettelExploreCmd(note, opts) {
|
|
|
22814
23092
|
const depth = opts.depth ?? 3;
|
|
22815
23093
|
const direction = opts.direction ?? "both";
|
|
22816
23094
|
const mode = opts.mode ?? "all";
|
|
22817
|
-
const { zettelExplore } = await import("../zettelkasten-
|
|
23095
|
+
const { zettelExplore } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
22818
23096
|
const result = await zettelExplore(await getFedBackend(), {
|
|
22819
23097
|
startNote: note,
|
|
22820
23098
|
depth,
|
|
@@ -22870,7 +23148,7 @@ async function zettelHealthCmd(opts) {
|
|
|
22870
23148
|
const projectPath = opts.project;
|
|
22871
23149
|
const recentDays = opts.days ?? 30;
|
|
22872
23150
|
const includeTypes = opts.include ? opts.include.split(",").map((s) => s.trim()) : void 0;
|
|
22873
|
-
const { zettelHealth } = await import("../zettelkasten-
|
|
23151
|
+
const { zettelHealth } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
22874
23152
|
const result = await zettelHealth(await getFedBackend(), {
|
|
22875
23153
|
scope,
|
|
22876
23154
|
projectPath,
|
|
@@ -22928,7 +23206,7 @@ async function zettelSurpriseCmd(note, opts) {
|
|
|
22928
23206
|
const limit = opts.limit ?? 10;
|
|
22929
23207
|
const minSimilarity = opts.minSimilarity ?? .3;
|
|
22930
23208
|
const minGraphDistance = opts.minDistance ?? 3;
|
|
22931
|
-
const { zettelSurprise } = await import("../zettelkasten-
|
|
23209
|
+
const { zettelSurprise } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
22932
23210
|
const backend = await getFedBackend();
|
|
22933
23211
|
console.log();
|
|
22934
23212
|
console.log(header(" PAI Zettel Surprise"));
|
|
@@ -22970,7 +23248,7 @@ async function zettelConverseCmd(question, opts) {
|
|
|
22970
23248
|
const vaultProjectId = Number(opts.vaultProjectId);
|
|
22971
23249
|
const depth = opts.depth ?? 2;
|
|
22972
23250
|
const limit = opts.limit ?? 15;
|
|
22973
|
-
const { zettelConverse } = await import("../zettelkasten-
|
|
23251
|
+
const { zettelConverse } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
22974
23252
|
const backend = await getFedBackend();
|
|
22975
23253
|
console.log();
|
|
22976
23254
|
console.log(header(" PAI Zettel Converse"));
|
|
@@ -23020,7 +23298,7 @@ async function zettelThemesCmd(opts) {
|
|
|
23020
23298
|
const minClusterSize = opts.minSize ?? 3;
|
|
23021
23299
|
const maxThemes = opts.maxThemes ?? 10;
|
|
23022
23300
|
const similarityThreshold = opts.threshold ?? .65;
|
|
23023
|
-
const { zettelThemes } = await import("../zettelkasten-
|
|
23301
|
+
const { zettelThemes } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
23024
23302
|
const backend = await getFedBackend();
|
|
23025
23303
|
console.log();
|
|
23026
23304
|
console.log(header(" PAI Zettel Themes"));
|
|
@@ -23068,7 +23346,7 @@ async function zettelSuggestCmd(note, opts) {
|
|
|
23068
23346
|
const vaultProjectId = Number(opts.vaultProjectId);
|
|
23069
23347
|
const limit = opts.limit ?? 5;
|
|
23070
23348
|
const excludeLinked = opts.excludeLinked !== true;
|
|
23071
|
-
const { zettelSuggest } = await import("../zettelkasten-
|
|
23349
|
+
const { zettelSuggest } = await import("../zettelkasten-BH8AdNh8.mjs");
|
|
23072
23350
|
const backend = await getFedBackend();
|
|
23073
23351
|
console.log();
|
|
23074
23352
|
console.log(header(" PAI Zettel Suggest"));
|
|
@@ -24067,28 +24345,6 @@ const reloadCommand = {
|
|
|
24067
24345
|
}
|
|
24068
24346
|
};
|
|
24069
24347
|
|
|
24070
|
-
//#endregion
|
|
24071
|
-
//#region src/audit/tokens.ts
|
|
24072
|
-
/**
|
|
24073
|
-
* tokens.ts — the one tokenizer every audit section counts through.
|
|
24074
|
-
*
|
|
24075
|
-
* cl100k_base via js-tiktoken (pure JS, no native build). "all" + [] for the
|
|
24076
|
-
* allowed/disallowed-special-token arguments matches Python tiktoken's
|
|
24077
|
-
* `disallowed_special=()`: audited text (hook stdout, session transcripts)
|
|
24078
|
-
* routinely contains literal "<|...|>"-shaped substrings that are not actual
|
|
24079
|
-
* special tokens, and the strict default throws on sight of them.
|
|
24080
|
-
*/
|
|
24081
|
-
const TOKEN_ENCODING = "cl100k_base";
|
|
24082
|
-
let cached = null;
|
|
24083
|
-
function encoder() {
|
|
24084
|
-
if (!cached) cached = getEncoding(TOKEN_ENCODING);
|
|
24085
|
-
return cached;
|
|
24086
|
-
}
|
|
24087
|
-
function countTokens(text) {
|
|
24088
|
-
if (!text) return 0;
|
|
24089
|
-
return encoder().encode(text, "all", []).length;
|
|
24090
|
-
}
|
|
24091
|
-
|
|
24092
24348
|
//#endregion
|
|
24093
24349
|
//#region src/audit/files.ts
|
|
24094
24350
|
/**
|
|
@@ -24182,149 +24438,41 @@ function auditFiles(paths) {
|
|
|
24182
24438
|
}
|
|
24183
24439
|
|
|
24184
24440
|
//#endregion
|
|
24185
|
-
//#region src/audit/
|
|
24441
|
+
//#region src/audit/first-turn.ts
|
|
24186
24442
|
/**
|
|
24187
|
-
*
|
|
24188
|
-
*
|
|
24189
|
-
*
|
|
24190
|
-
*
|
|
24443
|
+
* first-turn.ts — where a turn's context actually comes from, attributed
|
|
24444
|
+
* line by line from the transcript.
|
|
24445
|
+
*
|
|
24446
|
+
* The API reports one number per assistant turn (cache_read + cache_creation
|
|
24447
|
+
* + input). The transcript holds the pieces Claude Code injected since the
|
|
24448
|
+
* previous turn: SessionStart / UserPromptSubmit hook output, the skill
|
|
24449
|
+
* listing, the deferred-tool name list, MCP server instructions, the human
|
|
24450
|
+
* prompt, any isMeta expansion (a skill body, a pasted slash-command result),
|
|
24451
|
+
* tool results, and the previous turn's own visible output (re-sent as
|
|
24452
|
+
* context on every later turn). Each of those is counted in cl100k here;
|
|
24453
|
+
* what is left over is the system prompt, the built-in tool schemas, agents
|
|
24454
|
+
* and memory files, which never appear in the transcript.
|
|
24455
|
+
*
|
|
24456
|
+
* Only the live lineage counts. Lines chain by parentUuid, and a prompt the
|
|
24457
|
+
* user abandoned before a reply (Escape, re-submit) leaves a dead branch
|
|
24458
|
+
* whose lines were never sent — run 9 measured a session whose first
|
|
24459
|
+
* /Name was abandoned and read 24k where the true baseline is 30k, because
|
|
24460
|
+
* the 3.5k-token skill listing sat on the dead branch.
|
|
24461
|
+
*
|
|
24462
|
+
* `type:"system"` lines with subtype local_command hold a slash command's
|
|
24463
|
+
* raw stdout, and the model receives it verbatim in addition to the isMeta
|
|
24464
|
+
* markdown copy — run 11 measured both present on the same turn, so both are
|
|
24465
|
+
* counted.
|
|
24191
24466
|
*/
|
|
24192
|
-
|
|
24193
|
-
|
|
24194
|
-
|
|
24195
|
-
|
|
24196
|
-
|
|
24197
|
-
|
|
24198
|
-
|
|
24199
|
-
} catch {
|
|
24200
|
-
return {};
|
|
24467
|
+
function parseLines(path) {
|
|
24468
|
+
const lines = [];
|
|
24469
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
24470
|
+
if (!raw.trim()) continue;
|
|
24471
|
+
try {
|
|
24472
|
+
lines.push(JSON.parse(raw));
|
|
24473
|
+
} catch {}
|
|
24201
24474
|
}
|
|
24202
|
-
|
|
24203
|
-
/** Expand ${PAI_DIR} and $HOME/${HOME} the way settings.json's own hooks do. */
|
|
24204
|
-
function expandCommand(command, env) {
|
|
24205
|
-
const paiDir = env.PAI_DIR ?? process.env.PAI_DIR ?? claudeHomeDir();
|
|
24206
|
-
return command.replace(/\$\{PAI_DIR\}/g, paiDir).replace(/\$\{HOME\}/g, homedir()).replace(/\$HOME\b/g, homedir());
|
|
24207
|
-
}
|
|
24208
|
-
function syntheticStdin(cwd, eventName) {
|
|
24209
|
-
const sessionId = randomUUID();
|
|
24210
|
-
return JSON.stringify({
|
|
24211
|
-
session_id: sessionId,
|
|
24212
|
-
cwd,
|
|
24213
|
-
hook_event_name: eventName,
|
|
24214
|
-
prompt: "audit",
|
|
24215
|
-
transcript_path: join(claudeProjectsDir(), "audit", `${sessionId}.jsonl`)
|
|
24216
|
-
});
|
|
24217
|
-
}
|
|
24218
|
-
function runHook(command, cwd, eventName) {
|
|
24219
|
-
const result = spawnSync(command, {
|
|
24220
|
-
shell: true,
|
|
24221
|
-
cwd,
|
|
24222
|
-
input: syntheticStdin(cwd, eventName),
|
|
24223
|
-
encoding: "utf8",
|
|
24224
|
-
timeout: HOOK_TIMEOUT_MS
|
|
24225
|
-
});
|
|
24226
|
-
if (result.error) return {
|
|
24227
|
-
stdout: "",
|
|
24228
|
-
error: result.error.message
|
|
24229
|
-
};
|
|
24230
|
-
if (result.signal) return {
|
|
24231
|
-
stdout: result.stdout ?? "",
|
|
24232
|
-
error: `killed by ${result.signal} (timeout?)`
|
|
24233
|
-
};
|
|
24234
|
-
return { stdout: result.stdout ?? "" };
|
|
24235
|
-
}
|
|
24236
|
-
const NOT_RUN = "not run (pass --run to execute hooks)";
|
|
24237
|
-
const AUDITED_EVENTS = ["SessionStart", "UserPromptSubmit"];
|
|
24238
|
-
/**
|
|
24239
|
-
* Lists every SessionStart / UserPromptSubmit hook. Without `run` the hooks are only
|
|
24240
|
-
* listed (tokens 0, marked not run): a project's settings.json is untrusted input and
|
|
24241
|
-
* its hooks execute with shell:true. Pass `runHook` (CLI `--run`) to measure them.
|
|
24242
|
-
*/
|
|
24243
|
-
function auditHooks(cwd, run) {
|
|
24244
|
-
const settingsFiles = settingsPaths(cwd);
|
|
24245
|
-
const readings = [];
|
|
24246
|
-
const totalsByEvent = {};
|
|
24247
|
-
const preToolUseHooks = [];
|
|
24248
|
-
let preToolUseRewritesBash = false;
|
|
24249
|
-
for (const settingsPath of settingsFiles) {
|
|
24250
|
-
const settings = readSettings(settingsPath);
|
|
24251
|
-
const env = settings.env ?? {};
|
|
24252
|
-
for (const eventName of AUDITED_EVENTS) {
|
|
24253
|
-
const groups = settings.hooks?.[eventName] ?? [];
|
|
24254
|
-
totalsByEvent[eventName] = totalsByEvent[eventName] ?? 0;
|
|
24255
|
-
for (const group of groups) for (const entry of group.hooks ?? []) {
|
|
24256
|
-
if (!entry.command) continue;
|
|
24257
|
-
const command = expandCommand(entry.command, env);
|
|
24258
|
-
const { stdout, error } = run ? run(command, cwd, eventName) : {
|
|
24259
|
-
stdout: "",
|
|
24260
|
-
error: NOT_RUN
|
|
24261
|
-
};
|
|
24262
|
-
const tokens = countTokens(stdout);
|
|
24263
|
-
totalsByEvent[eventName] += tokens;
|
|
24264
|
-
readings.push({
|
|
24265
|
-
event: eventName,
|
|
24266
|
-
matcher: group.matcher,
|
|
24267
|
-
command,
|
|
24268
|
-
tokens,
|
|
24269
|
-
error
|
|
24270
|
-
});
|
|
24271
|
-
}
|
|
24272
|
-
}
|
|
24273
|
-
for (const group of settings.hooks?.PreToolUse ?? []) for (const entry of group.hooks ?? []) {
|
|
24274
|
-
if (!entry.command) continue;
|
|
24275
|
-
preToolUseHooks.push({
|
|
24276
|
-
matcher: group.matcher,
|
|
24277
|
-
command: expandCommand(entry.command, env)
|
|
24278
|
-
});
|
|
24279
|
-
if (group.matcher === "Bash") preToolUseRewritesBash = true;
|
|
24280
|
-
}
|
|
24281
|
-
}
|
|
24282
|
-
return {
|
|
24283
|
-
encoding: TOKEN_ENCODING,
|
|
24284
|
-
settingsFiles,
|
|
24285
|
-
readings,
|
|
24286
|
-
totalsByEvent,
|
|
24287
|
-
preToolUseHooks,
|
|
24288
|
-
preToolUseRewritesBash
|
|
24289
|
-
};
|
|
24290
|
-
}
|
|
24291
|
-
|
|
24292
|
-
//#endregion
|
|
24293
|
-
//#region src/audit/first-turn.ts
|
|
24294
|
-
/**
|
|
24295
|
-
* first-turn.ts — where a turn's context actually comes from, attributed
|
|
24296
|
-
* line by line from the transcript.
|
|
24297
|
-
*
|
|
24298
|
-
* The API reports one number per assistant turn (cache_read + cache_creation
|
|
24299
|
-
* + input). The transcript holds the pieces Claude Code injected since the
|
|
24300
|
-
* previous turn: SessionStart / UserPromptSubmit hook output, the skill
|
|
24301
|
-
* listing, the deferred-tool name list, MCP server instructions, the human
|
|
24302
|
-
* prompt, any isMeta expansion (a skill body, a pasted slash-command result),
|
|
24303
|
-
* tool results, and the previous turn's own visible output (re-sent as
|
|
24304
|
-
* context on every later turn). Each of those is counted in cl100k here;
|
|
24305
|
-
* what is left over is the system prompt, the built-in tool schemas, agents
|
|
24306
|
-
* and memory files, which never appear in the transcript.
|
|
24307
|
-
*
|
|
24308
|
-
* Only the live lineage counts. Lines chain by parentUuid, and a prompt the
|
|
24309
|
-
* user abandoned before a reply (Escape, re-submit) leaves a dead branch
|
|
24310
|
-
* whose lines were never sent — run 9 measured a session whose first
|
|
24311
|
-
* /Name was abandoned and read 24k where the true baseline is 30k, because
|
|
24312
|
-
* the 3.5k-token skill listing sat on the dead branch.
|
|
24313
|
-
*
|
|
24314
|
-
* `type:"system"` lines with subtype local_command hold a slash command's
|
|
24315
|
-
* raw stdout, and the model receives it verbatim in addition to the isMeta
|
|
24316
|
-
* markdown copy — run 11 measured both present on the same turn, so both are
|
|
24317
|
-
* counted.
|
|
24318
|
-
*/
|
|
24319
|
-
function parseLines(path) {
|
|
24320
|
-
const lines = [];
|
|
24321
|
-
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
24322
|
-
if (!raw.trim()) continue;
|
|
24323
|
-
try {
|
|
24324
|
-
lines.push(JSON.parse(raw));
|
|
24325
|
-
} catch {}
|
|
24326
|
-
}
|
|
24327
|
-
return lines;
|
|
24475
|
+
return lines;
|
|
24328
24476
|
}
|
|
24329
24477
|
function classify(line) {
|
|
24330
24478
|
if (line.type === "attachment") {
|
|
@@ -24743,8 +24891,8 @@ function deriveSessionTrigger(transcriptPath, env = process.env, now = Date.now(
|
|
|
24743
24891
|
* and the cache-TTL split (ephemeral 5m vs 1h) that says which cache the
|
|
24744
24892
|
* session is riding.
|
|
24745
24893
|
*/
|
|
24746
|
-
/** Newest *.jsonl under ~/.claude/projects, excluding subagents/. */
|
|
24747
|
-
function newestSessionLog() {
|
|
24894
|
+
/** Newest *.jsonl under ~/.claude/projects, excluding subagents/. With a session id, that session's file instead. */
|
|
24895
|
+
function newestSessionLog(sessionId) {
|
|
24748
24896
|
const projectsDir = claudeProjectsDir();
|
|
24749
24897
|
let best = null;
|
|
24750
24898
|
let projectDirs;
|
|
@@ -24763,6 +24911,7 @@ function newestSessionLog() {
|
|
|
24763
24911
|
}
|
|
24764
24912
|
for (const entry of entries) {
|
|
24765
24913
|
if (!entry.endsWith(".jsonl")) continue;
|
|
24914
|
+
if (sessionId && entry !== `${sessionId}.jsonl`) continue;
|
|
24766
24915
|
const filePath = join(dirPath, entry);
|
|
24767
24916
|
let mtime;
|
|
24768
24917
|
try {
|
|
@@ -26344,12 +26493,14 @@ function hookRunner(run) {
|
|
|
26344
26493
|
console.error(warn$1("--run executes every SessionStart/UserPromptSubmit hook in ~/.claude/settings.json and ./.claude/settings.json with a shell. Do not use it on a repository you do not trust."));
|
|
26345
26494
|
return runHook;
|
|
26346
26495
|
}
|
|
26347
|
-
function printHooks(report) {
|
|
26496
|
+
function printHooks(report, sinceHours) {
|
|
26348
26497
|
console.log(header(`Hook output (encoding: ${report.encoding})`));
|
|
26349
26498
|
console.log(dim$1(`settings.json: ${report.settingsFiles.join(", ") || "none found"}`));
|
|
26350
26499
|
const rows = report.readings.map((r) => [
|
|
26351
26500
|
r.event,
|
|
26352
26501
|
r.matcher ?? "",
|
|
26502
|
+
r.state,
|
|
26503
|
+
r.lastSeen ?? "",
|
|
26353
26504
|
String(r.tokens),
|
|
26354
26505
|
r.error ? err(r.error) : "",
|
|
26355
26506
|
r.command
|
|
@@ -26357,10 +26508,14 @@ function printHooks(report) {
|
|
|
26357
26508
|
console.log(renderTable$1([
|
|
26358
26509
|
"event",
|
|
26359
26510
|
"matcher",
|
|
26511
|
+
"state",
|
|
26512
|
+
"last seen",
|
|
26360
26513
|
"tokens",
|
|
26361
26514
|
"error",
|
|
26362
26515
|
"command"
|
|
26363
26516
|
], rows));
|
|
26517
|
+
for (const r of report.readings) if (r.state === "missing-script") console.log(err(` RED: ${r.event} hook script does not exist: ${r.command}`));
|
|
26518
|
+
else if (r.state === "configured") console.log(warn$1(` AMBER: ${r.event} hook configured, never observed in ${sinceHours}h — it may not be running`));
|
|
26364
26519
|
for (const [event, total] of Object.entries(report.totalsByEvent)) console.log(bold$1(`${event} total: ${total} tokens`));
|
|
26365
26520
|
console.log(dim$1(`PreToolUse hooks: ${report.preToolUseHooks.length}` + (report.preToolUseRewritesBash ? ", including a Bash-matcher rewrite hook" : "")));
|
|
26366
26521
|
}
|
|
@@ -26686,7 +26841,7 @@ const DEFAULT_CTX_THRESHOLD = 2e5;
|
|
|
26686
26841
|
async function gatherCombined() {
|
|
26687
26842
|
const cwd = process.cwd();
|
|
26688
26843
|
const files = auditFiles(defaultFileSet(cwd));
|
|
26689
|
-
const hooks = auditHooks(cwd);
|
|
26844
|
+
const hooks = auditHooks(cwd, { sinceHours: DEFAULT_SINCE_HOURS });
|
|
26690
26845
|
const session = await auditSession(newestSessionLog() ?? void 0).catch(() => null);
|
|
26691
26846
|
const daemon = auditDaemon();
|
|
26692
26847
|
const env = auditEnv();
|
|
@@ -26745,7 +26900,7 @@ function combinedSectionOutputs(data) {
|
|
|
26745
26900
|
});
|
|
26746
26901
|
sections.push({
|
|
26747
26902
|
name: "hooks",
|
|
26748
|
-
text: captureOutput(() => printHooks(data.hooks))
|
|
26903
|
+
text: captureOutput(() => printHooks(data.hooks, DEFAULT_SINCE_HOURS))
|
|
26749
26904
|
});
|
|
26750
26905
|
if (data.session) sections.push({
|
|
26751
26906
|
name: "session",
|
|
@@ -26828,6 +26983,71 @@ function recordCombinedReport(dir, data) {
|
|
|
26828
26983
|
for (const f of data.findings) counts[f.severity]++;
|
|
26829
26984
|
appendFileSync(runsPath, `- ${date} run${runNumber}: ${counts.RED} RED, ${counts.AMBER} AMBER, ${counts.GREEN} GREEN — ${baseName}.md\n`, "utf8");
|
|
26830
26985
|
}
|
|
26986
|
+
const SCAN_SEVERITIES = [
|
|
26987
|
+
"block",
|
|
26988
|
+
"confirm",
|
|
26989
|
+
"warn"
|
|
26990
|
+
];
|
|
26991
|
+
/**
|
|
26992
|
+
* `pai audit scan`: run the rule set over a transcript after the fact —
|
|
26993
|
+
* single-step rules per tool call, sequence rules correlated across the run.
|
|
26994
|
+
* Exit 0 when nothing hit, 1 when any block-severity finding did.
|
|
26995
|
+
*/
|
|
26996
|
+
async function auditScanCmd(opts) {
|
|
26997
|
+
let target = null;
|
|
26998
|
+
if (opts.worker) {
|
|
26999
|
+
const { workers } = readWorkersSection();
|
|
27000
|
+
const logDir = workersLogDir(workers);
|
|
27001
|
+
const st = loadStatus(logDir, opts.worker);
|
|
27002
|
+
if (!st) {
|
|
27003
|
+
console.error(err(` No worker named ${opts.worker}.`));
|
|
27004
|
+
return 1;
|
|
27005
|
+
}
|
|
27006
|
+
target = st.claudeSession ? claudeSessionTranscript(st.claudeSession, st.cwd) : eventsPath(logDir, opts.worker);
|
|
27007
|
+
if (target && !existsSync(target)) target = null;
|
|
27008
|
+
} else if (opts.session) target = newestSessionLog(opts.session);
|
|
27009
|
+
else if (opts.path) target = existsSync(opts.path) ? opts.path : null;
|
|
27010
|
+
else target = newestSessionLog();
|
|
27011
|
+
if (!target) {
|
|
27012
|
+
console.error(err(" No transcript found."));
|
|
27013
|
+
return 1;
|
|
27014
|
+
}
|
|
27015
|
+
const { rules, errors } = loadRules(userRuleDirs(process.cwd()));
|
|
27016
|
+
for (const e of errors) console.error(dim$1(`rule: ${e}`));
|
|
27017
|
+
const findings = await scanTranscript(target, rules);
|
|
27018
|
+
if (opts.json) {
|
|
27019
|
+
printJson({
|
|
27020
|
+
path: target,
|
|
27021
|
+
findings
|
|
27022
|
+
});
|
|
27023
|
+
return findings.some((f) => f.severity === "block") ? 1 : 0;
|
|
27024
|
+
}
|
|
27025
|
+
console.log(header(`Security scan: ${target}`));
|
|
27026
|
+
console.log(dim$1(`${rules.length} rules, ${findings.length} findings`));
|
|
27027
|
+
for (const sev of SCAN_SEVERITIES) {
|
|
27028
|
+
const hits = findings.filter((f) => f.severity === sev);
|
|
27029
|
+
if (!hits.length) continue;
|
|
27030
|
+
console.log(header(sev));
|
|
27031
|
+
const rows = hits.map((f) => [
|
|
27032
|
+
severityColor(sev === "block" ? "RED" : "AMBER", sev),
|
|
27033
|
+
f.ruleId,
|
|
27034
|
+
f.tool,
|
|
27035
|
+
String(f.line),
|
|
27036
|
+
f.ts ? f.ts.slice(11, 19) : "",
|
|
27037
|
+
f.evidence.replace(/\s+/g, " ").slice(0, 100)
|
|
27038
|
+
]);
|
|
27039
|
+
console.log(renderTable$1([
|
|
27040
|
+
"severity",
|
|
27041
|
+
"rule",
|
|
27042
|
+
"tool",
|
|
27043
|
+
"line",
|
|
27044
|
+
"time",
|
|
27045
|
+
"evidence"
|
|
27046
|
+
], rows));
|
|
27047
|
+
}
|
|
27048
|
+
if (!findings.length) console.log(ok$1("no findings"));
|
|
27049
|
+
return findings.some((f) => f.severity === "block") ? 1 : 0;
|
|
27050
|
+
}
|
|
26831
27051
|
/**
|
|
26832
27052
|
* `--json` is declared once, on `tokensCmd` itself, and read back from there
|
|
26833
27053
|
* in every subcommand action below — not redeclared per subcommand. The parser
|
|
@@ -26848,12 +27068,16 @@ async function auditFilesCmd(paths, opts) {
|
|
|
26848
27068
|
}
|
|
26849
27069
|
/** Exported function for audit tokens hooks command */
|
|
26850
27070
|
function auditHooksCmd(opts) {
|
|
26851
|
-
const
|
|
27071
|
+
const sinceHours = opts.since ? parseInt(opts.since, 10) : DEFAULT_SINCE_HOURS;
|
|
27072
|
+
const report = auditHooks(process.cwd(), {
|
|
27073
|
+
run: hookRunner(opts.run),
|
|
27074
|
+
sinceHours
|
|
27075
|
+
});
|
|
26852
27076
|
if (opts.json) {
|
|
26853
27077
|
printJson(report);
|
|
26854
27078
|
return 0;
|
|
26855
27079
|
}
|
|
26856
|
-
printHooks(report);
|
|
27080
|
+
printHooks(report, sinceHours);
|
|
26857
27081
|
return 0;
|
|
26858
27082
|
}
|
|
26859
27083
|
/** Exported function for audit tokens session command */
|
|
@@ -27041,7 +27265,8 @@ const t = (name, summary, description, extra = {}) => a({
|
|
|
27041
27265
|
if (name === "files") return auditFilesCmd(ctx.args.PATHS ?? [], { json: ctx.opts.json === true });
|
|
27042
27266
|
if (name === "hooks") return auditHooksCmd({
|
|
27043
27267
|
json: ctx.opts.json === true,
|
|
27044
|
-
run: ctx.opts.run === true
|
|
27268
|
+
run: ctx.opts.run === true,
|
|
27269
|
+
since: ctx.opts.since
|
|
27045
27270
|
});
|
|
27046
27271
|
if (name === "session") return auditSessionCmd(ctx.args.PATH, {
|
|
27047
27272
|
json: ctx.opts.json === true,
|
|
@@ -27114,13 +27339,22 @@ const auditCommand = {
|
|
|
27114
27339
|
}],
|
|
27115
27340
|
examples: ["pai audit tokens files", "pai audit tokens files ./CLAUDE.md --json"]
|
|
27116
27341
|
}),
|
|
27117
|
-
t("hooks", "Token cost of every start-up and prompt hook", "Lists each SessionStart and UserPromptSubmit hook. With --run it executes them and measures what each adds to the context; without it nothing is executed.", {
|
|
27342
|
+
t("hooks", "Token cost and observed state of every start-up and prompt hook", "Lists each SessionStart and UserPromptSubmit hook with its state: observed (a capture for that event exists in the window), configured (never observed — the hook may silently not be running), or missing-script. With --run it executes them and measures what each adds to the context; without it nothing is executed.", {
|
|
27118
27343
|
options: [{
|
|
27119
27344
|
name: "run",
|
|
27120
27345
|
type: "boolean",
|
|
27121
27346
|
summary: "Execute the hooks to measure their output (runs the repository's own hook commands; trusted repositories only)"
|
|
27347
|
+
}, {
|
|
27348
|
+
name: "since",
|
|
27349
|
+
type: "number",
|
|
27350
|
+
value: "HOURS",
|
|
27351
|
+
summary: "Observation window for the state column (default 24h)"
|
|
27122
27352
|
}],
|
|
27123
|
-
examples: [
|
|
27353
|
+
examples: [
|
|
27354
|
+
"pai audit tokens hooks",
|
|
27355
|
+
"pai audit tokens hooks --run",
|
|
27356
|
+
"pai audit tokens hooks --since 48"
|
|
27357
|
+
]
|
|
27124
27358
|
}),
|
|
27125
27359
|
t("session", "Token split for one session transcript", "Shows cache, input and output tokens of a session (default: the newest).", {
|
|
27126
27360
|
args: [{
|
|
@@ -27227,6 +27461,1606 @@ const auditCommand = {
|
|
|
27227
27461
|
examples: ["pai audit tokens ladder", "pai audit tokens ladder --live --model haiku"]
|
|
27228
27462
|
})
|
|
27229
27463
|
]
|
|
27464
|
+
}, a({
|
|
27465
|
+
name: "scan",
|
|
27466
|
+
summary: "After-the-fact security scan of a session or worker transcript",
|
|
27467
|
+
description: "Runs the rule set (the same rules the PreToolUse hook enforces, plus two-step sequence rules that only make sense after the fact) over a transcript: single-step rules against every tool call, sequence rules correlated across the run — credential read followed by an outbound call, download followed by execute, injected text followed by a shell call. Findings are grouped by severity; evidence is masked. Exit 1 when a block-severity finding hit.",
|
|
27468
|
+
args: [{
|
|
27469
|
+
name: "PATH",
|
|
27470
|
+
summary: "Transcript .jsonl (default: newest session under ~/.claude/projects)"
|
|
27471
|
+
}],
|
|
27472
|
+
options: [{
|
|
27473
|
+
name: "session",
|
|
27474
|
+
type: "string",
|
|
27475
|
+
value: "ID",
|
|
27476
|
+
summary: "Scan this Claude Code session's transcript"
|
|
27477
|
+
}, {
|
|
27478
|
+
name: "worker",
|
|
27479
|
+
type: "string",
|
|
27480
|
+
value: "ID",
|
|
27481
|
+
summary: "Scan a worker's transcript (its Claude session, else its event log)"
|
|
27482
|
+
}],
|
|
27483
|
+
examples: [
|
|
27484
|
+
"pai audit scan",
|
|
27485
|
+
"pai audit scan --worker w-20260101-abc",
|
|
27486
|
+
"pai audit scan ./session.jsonl --json"
|
|
27487
|
+
],
|
|
27488
|
+
seeAlso: ["pai rules list", "pai audit tokens session"]
|
|
27489
|
+
}, (ctx) => auditScanCmd({
|
|
27490
|
+
json: ctx.opts.json === true,
|
|
27491
|
+
session: ctx.opts.session,
|
|
27492
|
+
worker: ctx.opts.worker,
|
|
27493
|
+
path: ctx.args.PATH
|
|
27494
|
+
}))]
|
|
27495
|
+
};
|
|
27496
|
+
|
|
27497
|
+
//#endregion
|
|
27498
|
+
//#region src/session/git-notes.ts
|
|
27499
|
+
/** Link commits to the session/worker that wrote them via `git notes --ref=pai`. */
|
|
27500
|
+
const EDIT_TOOLS = new Set([
|
|
27501
|
+
"Edit",
|
|
27502
|
+
"Write",
|
|
27503
|
+
"MultiEdit",
|
|
27504
|
+
"NotebookEdit"
|
|
27505
|
+
]);
|
|
27506
|
+
const HOOK_MARKER = "# PAI-GIT-NOTES: managed by `pai git setup`";
|
|
27507
|
+
const NOTES_REF = "refs/notes/pai";
|
|
27508
|
+
const LOOKBACK_MS = 6 * 3600 * 1e3;
|
|
27509
|
+
const real = (p) => {
|
|
27510
|
+
try {
|
|
27511
|
+
return realpathSync(p);
|
|
27512
|
+
} catch {
|
|
27513
|
+
return resolve(p);
|
|
27514
|
+
}
|
|
27515
|
+
};
|
|
27516
|
+
function jsonlFiles(dir, sinceMs, out = []) {
|
|
27517
|
+
let names;
|
|
27518
|
+
try {
|
|
27519
|
+
names = readdirSync(dir);
|
|
27520
|
+
} catch {
|
|
27521
|
+
return out;
|
|
27522
|
+
}
|
|
27523
|
+
for (const n of names) {
|
|
27524
|
+
const p = join(dir, n);
|
|
27525
|
+
try {
|
|
27526
|
+
const st = statSync(p);
|
|
27527
|
+
if (st.isDirectory()) jsonlFiles(p, sinceMs, out);
|
|
27528
|
+
else if (/\.jsonl(\.1)?$/.test(n) && st.mtimeMs >= sinceMs) out.push(p);
|
|
27529
|
+
} catch {}
|
|
27530
|
+
}
|
|
27531
|
+
return out;
|
|
27532
|
+
}
|
|
27533
|
+
/** Sessions whose Edit/Write-style tool events (after `sinceMs`) touched any of `paths`. */
|
|
27534
|
+
function sessionsTouching(paths, sinceMs, captureDir) {
|
|
27535
|
+
const wanted = new Map(paths.map((p) => [real(p), p]));
|
|
27536
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
27537
|
+
for (const file of jsonlFiles(captureDir, sinceMs)) {
|
|
27538
|
+
let text;
|
|
27539
|
+
try {
|
|
27540
|
+
text = readFileSync(file, "utf8");
|
|
27541
|
+
} catch {
|
|
27542
|
+
continue;
|
|
27543
|
+
}
|
|
27544
|
+
for (const line of text.split("\n")) {
|
|
27545
|
+
if (!line.includes("file_path") && !line.includes("notebook_path")) continue;
|
|
27546
|
+
let e;
|
|
27547
|
+
try {
|
|
27548
|
+
e = JSON.parse(line);
|
|
27549
|
+
} catch {
|
|
27550
|
+
continue;
|
|
27551
|
+
}
|
|
27552
|
+
const tool = e.tool ?? e.payload?.tool_name;
|
|
27553
|
+
if (!EDIT_TOOLS.has(tool)) continue;
|
|
27554
|
+
const ts = typeof e.timestamp === "number" ? e.timestamp : Date.parse(e.timestamp);
|
|
27555
|
+
if (Number.isFinite(ts) && ts < sinceMs) continue;
|
|
27556
|
+
const input = e.input ?? e.payload?.tool_input ?? {};
|
|
27557
|
+
const fp = input.file_path ?? input.notebook_path;
|
|
27558
|
+
const sessionId = e.session ?? e.session_id;
|
|
27559
|
+
if (typeof fp !== "string" || typeof sessionId !== "string") continue;
|
|
27560
|
+
const orig = wanted.get(real(fp));
|
|
27561
|
+
if (!orig) continue;
|
|
27562
|
+
const workerId = e.workerId ?? e.worker_id ?? e.env?.PAI_WORKER_ID ?? e.payload?.env?.PAI_WORKER_ID;
|
|
27563
|
+
const cur = bySession.get(sessionId) ?? {
|
|
27564
|
+
sessionId,
|
|
27565
|
+
files: []
|
|
27566
|
+
};
|
|
27567
|
+
if (typeof workerId === "string" && !cur.workerId) cur.workerId = workerId;
|
|
27568
|
+
if (!cur.files.includes(orig)) cur.files.push(orig);
|
|
27569
|
+
bySession.set(sessionId, cur);
|
|
27570
|
+
}
|
|
27571
|
+
}
|
|
27572
|
+
return [...bySession.values()];
|
|
27573
|
+
}
|
|
27574
|
+
/** Note body: one `pai-session` line per session, plus `pai-worker` where known. */
|
|
27575
|
+
function noteText(entries) {
|
|
27576
|
+
const lines = [];
|
|
27577
|
+
for (const e of entries) {
|
|
27578
|
+
lines.push(`pai-session: ${e.sessionId}`);
|
|
27579
|
+
if (e.workerId) lines.push(`pai-worker: ${e.workerId}`);
|
|
27580
|
+
}
|
|
27581
|
+
return lines.join("\n");
|
|
27582
|
+
}
|
|
27583
|
+
/** Attach `text` to `commit` under refs/notes/pai; false on any error. */
|
|
27584
|
+
function writeNote(repo, commit, text) {
|
|
27585
|
+
try {
|
|
27586
|
+
execFileSync("git", [
|
|
27587
|
+
"notes",
|
|
27588
|
+
"--ref=pai",
|
|
27589
|
+
"add",
|
|
27590
|
+
"-f",
|
|
27591
|
+
"-m",
|
|
27592
|
+
text,
|
|
27593
|
+
commit
|
|
27594
|
+
], {
|
|
27595
|
+
cwd: repo,
|
|
27596
|
+
timeout: 3e3,
|
|
27597
|
+
stdio: "ignore"
|
|
27598
|
+
});
|
|
27599
|
+
return true;
|
|
27600
|
+
} catch {
|
|
27601
|
+
return false;
|
|
27602
|
+
}
|
|
27603
|
+
}
|
|
27604
|
+
const git = (repo, args, timeout = 1e3) => execFileSync("git", args, {
|
|
27605
|
+
cwd: repo,
|
|
27606
|
+
timeout,
|
|
27607
|
+
encoding: "utf8",
|
|
27608
|
+
stdio: [
|
|
27609
|
+
"ignore",
|
|
27610
|
+
"pipe",
|
|
27611
|
+
"ignore"
|
|
27612
|
+
]
|
|
27613
|
+
}).trim();
|
|
27614
|
+
const gitOk = (repo, args) => {
|
|
27615
|
+
try {
|
|
27616
|
+
git(repo, args);
|
|
27617
|
+
return true;
|
|
27618
|
+
} catch {
|
|
27619
|
+
return false;
|
|
27620
|
+
}
|
|
27621
|
+
};
|
|
27622
|
+
/** The post-commit work: note the sessions that edited the files of HEAD. Never throws. */
|
|
27623
|
+
function postCommitHook(repo, captureDir, env = process.env, now = Date.now()) {
|
|
27624
|
+
try {
|
|
27625
|
+
if (/rebase/.test(env.GIT_REFLOG_ACTION ?? "")) return false;
|
|
27626
|
+
if (gitOk(repo, [
|
|
27627
|
+
"rev-parse",
|
|
27628
|
+
"-q",
|
|
27629
|
+
"--verify",
|
|
27630
|
+
"REBASE_HEAD"
|
|
27631
|
+
]) || gitOk(repo, [
|
|
27632
|
+
"rev-parse",
|
|
27633
|
+
"-q",
|
|
27634
|
+
"--verify",
|
|
27635
|
+
"CHERRY_PICK_HEAD"
|
|
27636
|
+
])) return false;
|
|
27637
|
+
const top = git(repo, ["rev-parse", "--show-toplevel"]);
|
|
27638
|
+
const text = noteText(sessionsTouching(git(repo, [
|
|
27639
|
+
"diff-tree",
|
|
27640
|
+
"--root",
|
|
27641
|
+
"--no-commit-id",
|
|
27642
|
+
"--name-only",
|
|
27643
|
+
"-r",
|
|
27644
|
+
"HEAD"
|
|
27645
|
+
]).split("\n").filter(Boolean).map((f) => join(top, f)), now - LOOKBACK_MS, captureDir));
|
|
27646
|
+
return text ? writeNote(repo, "HEAD", text) : false;
|
|
27647
|
+
} catch {
|
|
27648
|
+
return false;
|
|
27649
|
+
}
|
|
27650
|
+
}
|
|
27651
|
+
function hookPath(repo) {
|
|
27652
|
+
const p = git(repo, [
|
|
27653
|
+
"rev-parse",
|
|
27654
|
+
"--git-path",
|
|
27655
|
+
"hooks/post-commit"
|
|
27656
|
+
]);
|
|
27657
|
+
return isAbsolute(p) ? p : resolve(repo, p);
|
|
27658
|
+
}
|
|
27659
|
+
/** Install the post-commit hook, chaining an existing foreign hook as post-commit.pre-pai. */
|
|
27660
|
+
function setupGitNotes(repo) {
|
|
27661
|
+
const hook = hookPath(repo);
|
|
27662
|
+
const old = `${hook}.pre-pai`;
|
|
27663
|
+
let chained = existsSync(old);
|
|
27664
|
+
if (existsSync(hook) && !readFileSync(hook, "utf8").includes(HOOK_MARKER)) {
|
|
27665
|
+
renameSync(hook, old);
|
|
27666
|
+
chained = true;
|
|
27667
|
+
}
|
|
27668
|
+
writeFileSync(hook, `#!/bin/sh\n${HOOK_MARKER}\n${chained ? "d=$(dirname \"$0\")\n[ -x \"$d/post-commit.pre-pai\" ] && \"$d/post-commit.pre-pai\" \"$@\"\n" : ""}pai git hook post-commit || true\n`);
|
|
27669
|
+
chmodSync(hook, 493);
|
|
27670
|
+
git(repo, [
|
|
27671
|
+
"config",
|
|
27672
|
+
"notes.rewriteRef",
|
|
27673
|
+
NOTES_REF
|
|
27674
|
+
]);
|
|
27675
|
+
return {
|
|
27676
|
+
hook,
|
|
27677
|
+
chained
|
|
27678
|
+
};
|
|
27679
|
+
}
|
|
27680
|
+
/** Remove the PAI hook and restore the chained one; false when PAI had no hook here. */
|
|
27681
|
+
function removeGitNotes(repo) {
|
|
27682
|
+
const hook = hookPath(repo);
|
|
27683
|
+
const old = `${hook}.pre-pai`;
|
|
27684
|
+
if (!existsSync(hook) || !readFileSync(hook, "utf8").includes(HOOK_MARKER)) return false;
|
|
27685
|
+
unlinkSync(hook);
|
|
27686
|
+
if (existsSync(old)) renameSync(old, hook);
|
|
27687
|
+
try {
|
|
27688
|
+
if (git(repo, ["config", "notes.rewriteRef"]) === NOTES_REF) git(repo, [
|
|
27689
|
+
"config",
|
|
27690
|
+
"--unset",
|
|
27691
|
+
"notes.rewriteRef"
|
|
27692
|
+
]);
|
|
27693
|
+
} catch {}
|
|
27694
|
+
return true;
|
|
27695
|
+
}
|
|
27696
|
+
/** The PAI note of `rev`, or undefined. */
|
|
27697
|
+
function showNote(repo, rev = "HEAD") {
|
|
27698
|
+
try {
|
|
27699
|
+
return git(repo, [
|
|
27700
|
+
"notes",
|
|
27701
|
+
"--ref=pai",
|
|
27702
|
+
"show",
|
|
27703
|
+
rev
|
|
27704
|
+
]) || void 0;
|
|
27705
|
+
} catch {
|
|
27706
|
+
return;
|
|
27707
|
+
}
|
|
27708
|
+
}
|
|
27709
|
+
|
|
27710
|
+
//#endregion
|
|
27711
|
+
//#region src/cli/commands/git.ts
|
|
27712
|
+
/** `pai git ...`: link commits to the session/worker that wrote them (git notes, ref pai). */
|
|
27713
|
+
const repoOrFail = () => process.cwd();
|
|
27714
|
+
const gitCommand = {
|
|
27715
|
+
name: "git",
|
|
27716
|
+
summary: "Link commits to the session that wrote them",
|
|
27717
|
+
description: "Installs a post-commit hook that records the session and worker that edited a commit's files as a git note (ref pai).",
|
|
27718
|
+
examples: [
|
|
27719
|
+
"pai git setup",
|
|
27720
|
+
"pai git notes",
|
|
27721
|
+
"pai git remove"
|
|
27722
|
+
],
|
|
27723
|
+
seeAlso: ["pai worker ps"],
|
|
27724
|
+
subcommands: [
|
|
27725
|
+
{
|
|
27726
|
+
name: "setup",
|
|
27727
|
+
summary: "Install the post-commit hook in the current repository",
|
|
27728
|
+
description: "Writes .git/hooks/post-commit, keeping an existing hook as post-commit.pre-pai and calling it first, and sets notes.rewriteRef so notes follow amends and rebases.",
|
|
27729
|
+
examples: ["pai git setup"],
|
|
27730
|
+
run: () => {
|
|
27731
|
+
try {
|
|
27732
|
+
const r = setupGitNotes(repoOrFail());
|
|
27733
|
+
process.stdout.write(`Installed ${r.hook}${r.chained ? " (existing hook chained)" : ""}\n`);
|
|
27734
|
+
return 0;
|
|
27735
|
+
} catch {
|
|
27736
|
+
throw new CliError("Not a git repository", "Run it inside a repository");
|
|
27737
|
+
}
|
|
27738
|
+
}
|
|
27739
|
+
},
|
|
27740
|
+
{
|
|
27741
|
+
name: "remove",
|
|
27742
|
+
summary: "Remove the hook and restore the previous one",
|
|
27743
|
+
description: "Deletes the PAI post-commit hook, puts a chained hook back and unsets notes.rewriteRef.",
|
|
27744
|
+
examples: ["pai git remove"],
|
|
27745
|
+
run: () => {
|
|
27746
|
+
process.stdout.write(removeGitNotes(repoOrFail()) ? "Removed the PAI post-commit hook\n" : "No PAI hook installed\n");
|
|
27747
|
+
return 0;
|
|
27748
|
+
}
|
|
27749
|
+
},
|
|
27750
|
+
{
|
|
27751
|
+
name: "notes",
|
|
27752
|
+
summary: "Show the PAI note of a commit",
|
|
27753
|
+
description: "Prints the session and worker recorded for a commit (default HEAD), or 'no PAI note'.",
|
|
27754
|
+
args: [{
|
|
27755
|
+
name: "REV",
|
|
27756
|
+
summary: "Commit to show (default HEAD)"
|
|
27757
|
+
}],
|
|
27758
|
+
examples: ["pai git notes", "pai git notes HEAD~1"],
|
|
27759
|
+
run: (ctx) => {
|
|
27760
|
+
process.stdout.write((showNote(repoOrFail(), ctx.args.REV) ?? "no PAI note") + "\n");
|
|
27761
|
+
return 0;
|
|
27762
|
+
}
|
|
27763
|
+
},
|
|
27764
|
+
{
|
|
27765
|
+
name: "hook",
|
|
27766
|
+
summary: "Git hook entry points (called by git)",
|
|
27767
|
+
description: "Internal: `pai git hook post-commit` is what the installed hook runs. Always exits 0.",
|
|
27768
|
+
examples: ["pai git hook post-commit"],
|
|
27769
|
+
hidden: true,
|
|
27770
|
+
subcommands: [{
|
|
27771
|
+
name: "post-commit",
|
|
27772
|
+
summary: "Record the sessions that edited the last commit's files",
|
|
27773
|
+
description: "Skips during rebase and cherry-pick; looks back 6 hours in the raw captures. Always exits 0.",
|
|
27774
|
+
examples: ["pai git hook post-commit"],
|
|
27775
|
+
run: () => {
|
|
27776
|
+
postCommitHook(process.cwd(), join(historyDir(), "raw-outputs"));
|
|
27777
|
+
return 0;
|
|
27778
|
+
}
|
|
27779
|
+
}]
|
|
27780
|
+
}
|
|
27781
|
+
]
|
|
27782
|
+
};
|
|
27783
|
+
|
|
27784
|
+
//#endregion
|
|
27785
|
+
//#region src/cli/commands/lessons.ts
|
|
27786
|
+
/** `pai lessons ...`: review-gated lessons (candidate, approved, rejected, superseded). */
|
|
27787
|
+
const ID = {
|
|
27788
|
+
name: "ID",
|
|
27789
|
+
required: true,
|
|
27790
|
+
summary: "Lesson id (its file name without .md)"
|
|
27791
|
+
};
|
|
27792
|
+
function state(v) {
|
|
27793
|
+
if (v === void 0) return void 0;
|
|
27794
|
+
if (!LESSON_STATES.includes(v)) throw new CliError(`unknown state "${v}" (${LESSON_STATES.join("|")})`);
|
|
27795
|
+
return v;
|
|
27796
|
+
}
|
|
27797
|
+
function row(l) {
|
|
27798
|
+
return `${l.id} [${l.state}] ${l.kind} ${l.project} ${l.title}`;
|
|
27799
|
+
}
|
|
27800
|
+
function wrap(f) {
|
|
27801
|
+
try {
|
|
27802
|
+
return f();
|
|
27803
|
+
} catch (e) {
|
|
27804
|
+
throw new CliError(e instanceof Error ? e.message : String(e));
|
|
27805
|
+
}
|
|
27806
|
+
}
|
|
27807
|
+
/** Newest transcript of the current directory under ~/.claude/projects. */
|
|
27808
|
+
function newestTranscriptOfCwd() {
|
|
27809
|
+
const dir = join(claudeProjectsDir(), process.cwd().replace(/[^a-zA-Z0-9]/g, "-"));
|
|
27810
|
+
if (!existsSync(dir)) return null;
|
|
27811
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => ({
|
|
27812
|
+
f: join(dir, f),
|
|
27813
|
+
m: statSync(join(dir, f)).mtimeMs
|
|
27814
|
+
}));
|
|
27815
|
+
files.sort((a, b) => b.m - a.m);
|
|
27816
|
+
return files[0]?.f ?? null;
|
|
27817
|
+
}
|
|
27818
|
+
const transition = (name, to, summary) => ({
|
|
27819
|
+
name,
|
|
27820
|
+
summary,
|
|
27821
|
+
description: `${summary}. Only candidates can be ${name}d.`,
|
|
27822
|
+
args: [ID],
|
|
27823
|
+
examples: [`pai lessons ${name} my-lesson`],
|
|
27824
|
+
run: (ctx) => {
|
|
27825
|
+
const l = wrap(() => setState(ctx.args.ID, to));
|
|
27826
|
+
console.log(`${l.id}: ${l.state}`);
|
|
27827
|
+
return 0;
|
|
27828
|
+
}
|
|
27829
|
+
});
|
|
27830
|
+
const lessonsCommand = {
|
|
27831
|
+
name: "lessons",
|
|
27832
|
+
summary: "Review-gated lessons for agents",
|
|
27833
|
+
description: "A short curated memory layer, one Markdown file per lesson in the PAI home. Candidates only reach agents once approved.",
|
|
27834
|
+
examples: [
|
|
27835
|
+
"pai lessons list --state candidate",
|
|
27836
|
+
"pai lessons propose",
|
|
27837
|
+
"pai lessons approve my-lesson"
|
|
27838
|
+
],
|
|
27839
|
+
seeAlso: ["pai memory"],
|
|
27840
|
+
subcommands: [
|
|
27841
|
+
{
|
|
27842
|
+
name: "list",
|
|
27843
|
+
summary: "List lessons",
|
|
27844
|
+
description: "Lists lessons, optionally filtered by state or project.",
|
|
27845
|
+
options: [
|
|
27846
|
+
{
|
|
27847
|
+
name: "state",
|
|
27848
|
+
type: "string",
|
|
27849
|
+
value: "S",
|
|
27850
|
+
summary: "candidate, approved, rejected or superseded"
|
|
27851
|
+
},
|
|
27852
|
+
{
|
|
27853
|
+
name: "project",
|
|
27854
|
+
type: "string",
|
|
27855
|
+
value: "P",
|
|
27856
|
+
summary: "Project slug or global"
|
|
27857
|
+
},
|
|
27858
|
+
{
|
|
27859
|
+
...OPT.json,
|
|
27860
|
+
summary: "Print JSON"
|
|
27861
|
+
}
|
|
27862
|
+
],
|
|
27863
|
+
examples: ["pai lessons list", "pai lessons list --state approved --project global"],
|
|
27864
|
+
run: (ctx) => {
|
|
27865
|
+
const ls = wrap(() => listLessons({
|
|
27866
|
+
state: state(ctx.opts.state),
|
|
27867
|
+
project: ctx.opts.project
|
|
27868
|
+
}));
|
|
27869
|
+
if (ctx.opts.json === true) console.log(JSON.stringify(ls, null, 2));
|
|
27870
|
+
else if (ls.length === 0) console.log("No lessons.");
|
|
27871
|
+
else for (const l of ls) console.log(row(l));
|
|
27872
|
+
return 0;
|
|
27873
|
+
}
|
|
27874
|
+
},
|
|
27875
|
+
{
|
|
27876
|
+
name: "show",
|
|
27877
|
+
summary: "Show one lesson",
|
|
27878
|
+
description: "Prints a lesson with its frontmatter fields and body.",
|
|
27879
|
+
args: [ID],
|
|
27880
|
+
examples: ["pai lessons show my-lesson"],
|
|
27881
|
+
run: (ctx) => {
|
|
27882
|
+
const l = getLesson(ctx.args.ID);
|
|
27883
|
+
if (!l) throw new CliError(`lesson not found: ${ctx.args.ID}`);
|
|
27884
|
+
console.log(row(l));
|
|
27885
|
+
console.log(`applies when: ${l.applies_when || "(not set)"}`);
|
|
27886
|
+
console.log(`evidence: ${l.evidence.join(", ") || "(none)"}`);
|
|
27887
|
+
if (l.superseded_by) console.log(`superseded by: ${l.superseded_by}`);
|
|
27888
|
+
console.log(`\n${l.body}`);
|
|
27889
|
+
return 0;
|
|
27890
|
+
}
|
|
27891
|
+
},
|
|
27892
|
+
{
|
|
27893
|
+
name: "add",
|
|
27894
|
+
summary: "Add a candidate lesson",
|
|
27895
|
+
description: "Creates a lesson in the candidate state; approve it after review.",
|
|
27896
|
+
options: [
|
|
27897
|
+
{
|
|
27898
|
+
name: "title",
|
|
27899
|
+
type: "string",
|
|
27900
|
+
value: "T",
|
|
27901
|
+
summary: "Short title"
|
|
27902
|
+
},
|
|
27903
|
+
{
|
|
27904
|
+
name: "kind",
|
|
27905
|
+
type: "string",
|
|
27906
|
+
value: "K",
|
|
27907
|
+
summary: LESSON_KINDS.join(", ")
|
|
27908
|
+
},
|
|
27909
|
+
{
|
|
27910
|
+
name: "applies-when",
|
|
27911
|
+
type: "string",
|
|
27912
|
+
value: "W",
|
|
27913
|
+
summary: "One sentence: when the lesson applies"
|
|
27914
|
+
},
|
|
27915
|
+
{
|
|
27916
|
+
name: "project",
|
|
27917
|
+
type: "string",
|
|
27918
|
+
value: "P",
|
|
27919
|
+
summary: "Project slug (default: global)"
|
|
27920
|
+
},
|
|
27921
|
+
{
|
|
27922
|
+
name: "body",
|
|
27923
|
+
type: "string",
|
|
27924
|
+
value: "B",
|
|
27925
|
+
summary: "The lesson in 1-5 sentences"
|
|
27926
|
+
}
|
|
27927
|
+
],
|
|
27928
|
+
examples: ["pai lessons add --title \"Run tsc first\" --kind workflow --applies-when \"before committing\""],
|
|
27929
|
+
run: (ctx) => {
|
|
27930
|
+
const { title, kind } = ctx.opts;
|
|
27931
|
+
const when = ctx.opts["applies-when"];
|
|
27932
|
+
if (!title || !kind || !when) throw new CliError("--title, --kind and --applies-when are required");
|
|
27933
|
+
const l = wrap(() => addLesson({
|
|
27934
|
+
title: String(title),
|
|
27935
|
+
kind,
|
|
27936
|
+
applies_when: String(when),
|
|
27937
|
+
project: ctx.opts.project,
|
|
27938
|
+
body: ctx.opts.body
|
|
27939
|
+
}));
|
|
27940
|
+
console.log(`added ${l.id} [candidate]`);
|
|
27941
|
+
return 0;
|
|
27942
|
+
}
|
|
27943
|
+
},
|
|
27944
|
+
transition("approve", "approved", "Approve a candidate"),
|
|
27945
|
+
transition("reject", "rejected", "Reject a candidate"),
|
|
27946
|
+
{
|
|
27947
|
+
name: "promote",
|
|
27948
|
+
summary: "Promote an approved lesson to a project skill",
|
|
27949
|
+
description: "Writes the lesson as a SKILL.md under <project>/.claude/skills/<slug>/ (and with --agents also <project>/.agents/skills/<slug>/). Refuses to overwrite an existing skill unless --force.",
|
|
27950
|
+
args: [ID],
|
|
27951
|
+
options: [
|
|
27952
|
+
{
|
|
27953
|
+
name: "project",
|
|
27954
|
+
type: "string",
|
|
27955
|
+
value: "DIR",
|
|
27956
|
+
summary: "Project directory (default: this directory)"
|
|
27957
|
+
},
|
|
27958
|
+
{
|
|
27959
|
+
name: "force",
|
|
27960
|
+
type: "boolean",
|
|
27961
|
+
summary: "Overwrite an existing SKILL.md"
|
|
27962
|
+
},
|
|
27963
|
+
{
|
|
27964
|
+
name: "agents",
|
|
27965
|
+
type: "boolean",
|
|
27966
|
+
summary: "Also write under .agents/skills"
|
|
27967
|
+
}
|
|
27968
|
+
],
|
|
27969
|
+
examples: ["pai lessons promote my-lesson --project ~/code/app --agents"],
|
|
27970
|
+
run: (ctx) => {
|
|
27971
|
+
const paths = wrap(() => promoteLesson(ctx.args.ID, {
|
|
27972
|
+
projectDir: ctx.opts.project ?? process.cwd(),
|
|
27973
|
+
force: ctx.opts.force === true,
|
|
27974
|
+
agentsDir: ctx.opts.agents === true
|
|
27975
|
+
}));
|
|
27976
|
+
for (const p of paths) console.log(`promoted ${p}`);
|
|
27977
|
+
return 0;
|
|
27978
|
+
}
|
|
27979
|
+
},
|
|
27980
|
+
{
|
|
27981
|
+
name: "supersede",
|
|
27982
|
+
summary: "Replace an approved lesson by another approved one",
|
|
27983
|
+
description: "Marks ID superseded; the replacement given with --by must exist and be approved.",
|
|
27984
|
+
args: [ID],
|
|
27985
|
+
options: [{
|
|
27986
|
+
name: "by",
|
|
27987
|
+
type: "string",
|
|
27988
|
+
value: "ID2",
|
|
27989
|
+
summary: "The approved lesson that replaces it"
|
|
27990
|
+
}],
|
|
27991
|
+
examples: ["pai lessons supersede old-lesson --by new-lesson"],
|
|
27992
|
+
run: (ctx) => {
|
|
27993
|
+
const by = ctx.opts.by;
|
|
27994
|
+
if (!by) throw new CliError("--by is required");
|
|
27995
|
+
const l = wrap(() => setState(ctx.args.ID, "superseded", { supersededBy: by }));
|
|
27996
|
+
console.log(`${l.id}: superseded by ${by}`);
|
|
27997
|
+
return 0;
|
|
27998
|
+
}
|
|
27999
|
+
},
|
|
28000
|
+
{
|
|
28001
|
+
name: "propose",
|
|
28002
|
+
summary: "Add candidates from a transcript",
|
|
28003
|
+
description: "Finds user messages that correct the assistant (no model call) and adds each as a candidate to review.",
|
|
28004
|
+
args: [{
|
|
28005
|
+
name: "TRANSCRIPT",
|
|
28006
|
+
summary: "Transcript file (default: newest of this directory)"
|
|
28007
|
+
}],
|
|
28008
|
+
examples: ["pai lessons propose", "pai lessons propose ./session.jsonl"],
|
|
28009
|
+
run: (ctx) => {
|
|
28010
|
+
const path = ctx.args.TRANSCRIPT ?? newestTranscriptOfCwd();
|
|
28011
|
+
if (!path) throw new CliError("no transcript found for this directory");
|
|
28012
|
+
const added = wrap(() => proposeFromTranscript(path).map(addLesson));
|
|
28013
|
+
if (added.length === 0) console.log("No corrections found.");
|
|
28014
|
+
for (const l of added) console.log(`added ${l.id} [candidate] ${l.title}`);
|
|
28015
|
+
return 0;
|
|
28016
|
+
}
|
|
28017
|
+
}
|
|
28018
|
+
]
|
|
28019
|
+
};
|
|
28020
|
+
|
|
28021
|
+
//#endregion
|
|
28022
|
+
//#region src/harness/claude-code.ts
|
|
28023
|
+
/**
|
|
28024
|
+
* claude-code.ts — adapter over Claude Code's JSONL transcripts:
|
|
28025
|
+
* `<claude home>/projects/<encoded cwd>/<session id>.jsonl`. The encoded-cwd
|
|
28026
|
+
* spelling is the one PAI already uses everywhere (encodeDir, src/cli/lib/utils.ts).
|
|
28027
|
+
*/
|
|
28028
|
+
/** Text of a tool_result's content: a string or its text blocks joined. */
|
|
28029
|
+
function resultText(c) {
|
|
28030
|
+
if (typeof c === "string") return c;
|
|
28031
|
+
if (Array.isArray(c)) return c.map((b) => b && typeof b === "object" && typeof b.text === "string" ? b.text : "").join("\n");
|
|
28032
|
+
return "";
|
|
28033
|
+
}
|
|
28034
|
+
/** Exit code a Bash result shows ("Exit code N" header or an exitCode field); else undefined. */
|
|
28035
|
+
function exitCodeOf(content) {
|
|
28036
|
+
if (typeof content === "string") {
|
|
28037
|
+
const m = /^Exit code (\d+)/m.exec(content);
|
|
28038
|
+
return m ? Number(m[1]) : void 0;
|
|
28039
|
+
}
|
|
28040
|
+
if (content && typeof content === "object" && !Array.isArray(content)) {
|
|
28041
|
+
const v = content.exitCode;
|
|
28042
|
+
if (typeof v === "number") return v;
|
|
28043
|
+
}
|
|
28044
|
+
}
|
|
28045
|
+
/** Events of one parsed transcript line; nothing for lines that carry no event. */
|
|
28046
|
+
function eventsOfLine(harness, sessionId, obj) {
|
|
28047
|
+
const ts = obj.timestamp ?? "";
|
|
28048
|
+
const content = obj.message?.content;
|
|
28049
|
+
const out = [];
|
|
28050
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
28051
|
+
if (obj.type === "user") {
|
|
28052
|
+
for (const b of blocks) if (b.type === "tool_result") out.push({
|
|
28053
|
+
harness,
|
|
28054
|
+
sessionId,
|
|
28055
|
+
ts,
|
|
28056
|
+
kind: "tool_result",
|
|
28057
|
+
text: resultText(b.content),
|
|
28058
|
+
exitCode: exitCodeOf(b.content),
|
|
28059
|
+
source: "transcript",
|
|
28060
|
+
fidelity: "observed"
|
|
28061
|
+
});
|
|
28062
|
+
if (!obj.isMeta) {
|
|
28063
|
+
const text = typeof content === "string" ? content : blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
28064
|
+
if (text) out.push({
|
|
28065
|
+
harness,
|
|
28066
|
+
sessionId,
|
|
28067
|
+
ts,
|
|
28068
|
+
kind: "user",
|
|
28069
|
+
text,
|
|
28070
|
+
source: "transcript",
|
|
28071
|
+
fidelity: "observed"
|
|
28072
|
+
});
|
|
28073
|
+
}
|
|
28074
|
+
return out;
|
|
28075
|
+
}
|
|
28076
|
+
if (obj.type === "assistant" && Array.isArray(content)) {
|
|
28077
|
+
for (const b of content) if (b.type === "text" && b.text) out.push({
|
|
28078
|
+
harness,
|
|
28079
|
+
sessionId,
|
|
28080
|
+
ts,
|
|
28081
|
+
kind: "assistant",
|
|
28082
|
+
text: b.text,
|
|
28083
|
+
source: "transcript",
|
|
28084
|
+
fidelity: "observed"
|
|
28085
|
+
});
|
|
28086
|
+
else if (b.type === "tool_use" && b.name) out.push({
|
|
28087
|
+
harness,
|
|
28088
|
+
sessionId,
|
|
28089
|
+
ts,
|
|
28090
|
+
kind: "tool_call",
|
|
28091
|
+
tool: b.name,
|
|
28092
|
+
input: b.input,
|
|
28093
|
+
source: "transcript",
|
|
28094
|
+
fidelity: "observed"
|
|
28095
|
+
});
|
|
28096
|
+
}
|
|
28097
|
+
return out;
|
|
28098
|
+
}
|
|
28099
|
+
/** Session files under a project dir: top-level `<id>.jsonl` only — the
|
|
28100
|
+
* `sessions/` archive holds hardlinks of the same files, and subagent logs
|
|
28101
|
+
* live in their own subdirs. */
|
|
28102
|
+
function sessionFiles(dir) {
|
|
28103
|
+
try {
|
|
28104
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
28105
|
+
} catch {
|
|
28106
|
+
return [];
|
|
28107
|
+
}
|
|
28108
|
+
}
|
|
28109
|
+
function toSession$1(harness, file, dir) {
|
|
28110
|
+
const id = file.slice(0, -6);
|
|
28111
|
+
let startedAt;
|
|
28112
|
+
let updatedAt;
|
|
28113
|
+
let cwd;
|
|
28114
|
+
try {
|
|
28115
|
+
for (const raw of readFileSync(join(dir, file), "utf8").split("\n")) {
|
|
28116
|
+
if (!raw.trim()) continue;
|
|
28117
|
+
let obj;
|
|
28118
|
+
try {
|
|
28119
|
+
obj = JSON.parse(raw);
|
|
28120
|
+
} catch {
|
|
28121
|
+
continue;
|
|
28122
|
+
}
|
|
28123
|
+
if (typeof obj.cwd === "string" && obj.cwd) cwd ??= obj.cwd;
|
|
28124
|
+
if (typeof obj.timestamp === "string" && obj.timestamp) {
|
|
28125
|
+
startedAt ??= obj.timestamp;
|
|
28126
|
+
updatedAt = obj.timestamp;
|
|
28127
|
+
}
|
|
28128
|
+
}
|
|
28129
|
+
} catch {}
|
|
28130
|
+
const mtime = new Date(statSync(join(dir, file)).mtimeMs).toISOString();
|
|
28131
|
+
return {
|
|
28132
|
+
harness,
|
|
28133
|
+
id,
|
|
28134
|
+
cwd,
|
|
28135
|
+
path: join(dir, file),
|
|
28136
|
+
startedAt,
|
|
28137
|
+
updatedAt: updatedAt ?? mtime
|
|
28138
|
+
};
|
|
28139
|
+
}
|
|
28140
|
+
const claudeCodeAdapter = {
|
|
28141
|
+
name: "claude-code",
|
|
28142
|
+
detect() {
|
|
28143
|
+
return existsSync(claudeProjectsDir());
|
|
28144
|
+
},
|
|
28145
|
+
listSessions(opts = {}) {
|
|
28146
|
+
const projectsDir = claudeProjectsDir();
|
|
28147
|
+
if (!existsSync(projectsDir)) return [];
|
|
28148
|
+
const dirs = opts.cwd ? [encodeDir(opts.cwd)] : readdirSafe(projectsDir);
|
|
28149
|
+
const out = [];
|
|
28150
|
+
for (const d of dirs) {
|
|
28151
|
+
const dir = join(projectsDir, d);
|
|
28152
|
+
if (!existsSync(dir)) continue;
|
|
28153
|
+
for (const f of sessionFiles(dir)) out.push(toSession$1(this.name, f, dir));
|
|
28154
|
+
}
|
|
28155
|
+
const since = opts.sinceMs !== void 0 ? Date.now() - opts.sinceMs : void 0;
|
|
28156
|
+
return out.filter((s) => since === void 0 || Date.parse(s.updatedAt) >= since).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
28157
|
+
},
|
|
28158
|
+
*readEvents(session) {
|
|
28159
|
+
let text;
|
|
28160
|
+
try {
|
|
28161
|
+
text = readFileSync(session.path, "utf8");
|
|
28162
|
+
} catch {
|
|
28163
|
+
return;
|
|
28164
|
+
}
|
|
28165
|
+
for (const raw of text.split("\n")) {
|
|
28166
|
+
if (!raw.trim()) continue;
|
|
28167
|
+
let obj;
|
|
28168
|
+
try {
|
|
28169
|
+
obj = JSON.parse(raw);
|
|
28170
|
+
} catch {
|
|
28171
|
+
continue;
|
|
28172
|
+
}
|
|
28173
|
+
yield* eventsOfLine(this.name, session.id, obj);
|
|
28174
|
+
}
|
|
28175
|
+
}
|
|
28176
|
+
};
|
|
28177
|
+
function readdirSafe(dir) {
|
|
28178
|
+
try {
|
|
28179
|
+
return readdirSync(dir);
|
|
28180
|
+
} catch {
|
|
28181
|
+
return [];
|
|
28182
|
+
}
|
|
28183
|
+
}
|
|
28184
|
+
|
|
28185
|
+
//#endregion
|
|
28186
|
+
//#region src/harness/codex.ts
|
|
28187
|
+
/**
|
|
28188
|
+
* codex.ts — adapter over Codex CLI session logs:
|
|
28189
|
+
* `<codex home>/sessions/**\/rollout-*.jsonl`. Implemented against the
|
|
28190
|
+
* documented rollout shape (JSON lines; `type:"message"` with role
|
|
28191
|
+
* user/assistant, `function_call` / `function_call_output`); unknown entry
|
|
28192
|
+
* types and malformed lines are skipped rather than failing the session.
|
|
28193
|
+
*/
|
|
28194
|
+
/** Codex CLI's config dir: CODEX_HOME if set, else ~/.codex. */
|
|
28195
|
+
function codexHomeDir() {
|
|
28196
|
+
return process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
28197
|
+
}
|
|
28198
|
+
/** Plain text of a message payload's content: a string, or text parts joined. */
|
|
28199
|
+
function contentText(c) {
|
|
28200
|
+
if (typeof c === "string") return c;
|
|
28201
|
+
if (Array.isArray(c)) return c.map((p) => {
|
|
28202
|
+
if (typeof p === "string") return p;
|
|
28203
|
+
if (p && typeof p === "object") {
|
|
28204
|
+
const o = p;
|
|
28205
|
+
if (typeof o.text === "string") return o.text;
|
|
28206
|
+
}
|
|
28207
|
+
return "";
|
|
28208
|
+
}).filter(Boolean).join("\n");
|
|
28209
|
+
return "";
|
|
28210
|
+
}
|
|
28211
|
+
/** function_call arguments: parsed as JSON when they are a JSON string. */
|
|
28212
|
+
function parseArgs(a) {
|
|
28213
|
+
if (typeof a !== "string") return a;
|
|
28214
|
+
try {
|
|
28215
|
+
return JSON.parse(a);
|
|
28216
|
+
} catch {
|
|
28217
|
+
return a;
|
|
28218
|
+
}
|
|
28219
|
+
}
|
|
28220
|
+
/** Events of one parsed rollout entry; nothing for entries that carry none. */
|
|
28221
|
+
function eventsOfEntry(sessionId, e) {
|
|
28222
|
+
const ts = e.timestamp ?? "";
|
|
28223
|
+
const p = e.payload;
|
|
28224
|
+
if (!p || !p.type) return [];
|
|
28225
|
+
if (p.type === "message" && (p.role === "user" || p.role === "assistant")) {
|
|
28226
|
+
const text = contentText(p.content);
|
|
28227
|
+
if (!text) return [];
|
|
28228
|
+
return [{
|
|
28229
|
+
harness: NAME,
|
|
28230
|
+
sessionId,
|
|
28231
|
+
ts,
|
|
28232
|
+
kind: p.role,
|
|
28233
|
+
text,
|
|
28234
|
+
source: "transcript",
|
|
28235
|
+
fidelity: "observed"
|
|
28236
|
+
}];
|
|
28237
|
+
}
|
|
28238
|
+
if (p.type === "function_call" && p.name) return [{
|
|
28239
|
+
harness: NAME,
|
|
28240
|
+
sessionId,
|
|
28241
|
+
ts,
|
|
28242
|
+
kind: "tool_call",
|
|
28243
|
+
tool: p.name,
|
|
28244
|
+
input: parseArgs(p.arguments),
|
|
28245
|
+
source: "transcript",
|
|
28246
|
+
fidelity: "observed"
|
|
28247
|
+
}];
|
|
28248
|
+
if (p.type === "function_call_output") return [{
|
|
28249
|
+
harness: NAME,
|
|
28250
|
+
sessionId,
|
|
28251
|
+
ts,
|
|
28252
|
+
kind: "tool_result",
|
|
28253
|
+
text: contentText(p.output),
|
|
28254
|
+
source: "transcript",
|
|
28255
|
+
fidelity: "observed"
|
|
28256
|
+
}];
|
|
28257
|
+
return [];
|
|
28258
|
+
}
|
|
28259
|
+
const NAME = "codex";
|
|
28260
|
+
/** rollout-*.jsonl under `dir`, recursing into subdirectories. */
|
|
28261
|
+
function rolloutFiles(dir) {
|
|
28262
|
+
const out = [];
|
|
28263
|
+
let entries;
|
|
28264
|
+
try {
|
|
28265
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
28266
|
+
} catch {
|
|
28267
|
+
return out;
|
|
28268
|
+
}
|
|
28269
|
+
for (const ent of entries) {
|
|
28270
|
+
const p = join(dir, ent.name);
|
|
28271
|
+
if (ent.isDirectory()) out.push(...rolloutFiles(p));
|
|
28272
|
+
else if (ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) out.push(p);
|
|
28273
|
+
}
|
|
28274
|
+
return out;
|
|
28275
|
+
}
|
|
28276
|
+
function toSession(file) {
|
|
28277
|
+
const id = file.slice(0, -6).split("/").pop() ?? file;
|
|
28278
|
+
let startedAt;
|
|
28279
|
+
let updatedAt;
|
|
28280
|
+
try {
|
|
28281
|
+
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
|
28282
|
+
if (!raw.trim()) continue;
|
|
28283
|
+
let e;
|
|
28284
|
+
try {
|
|
28285
|
+
e = JSON.parse(raw);
|
|
28286
|
+
} catch {
|
|
28287
|
+
continue;
|
|
28288
|
+
}
|
|
28289
|
+
if (typeof e.timestamp === "string" && e.timestamp) {
|
|
28290
|
+
startedAt ??= e.timestamp;
|
|
28291
|
+
updatedAt = e.timestamp;
|
|
28292
|
+
}
|
|
28293
|
+
}
|
|
28294
|
+
} catch {}
|
|
28295
|
+
const mtime = new Date(statSync(file).mtimeMs).toISOString();
|
|
28296
|
+
return {
|
|
28297
|
+
harness: NAME,
|
|
28298
|
+
id,
|
|
28299
|
+
path: file,
|
|
28300
|
+
startedAt,
|
|
28301
|
+
updatedAt: updatedAt ?? mtime
|
|
28302
|
+
};
|
|
28303
|
+
}
|
|
28304
|
+
const codexAdapter = {
|
|
28305
|
+
name: NAME,
|
|
28306
|
+
detect() {
|
|
28307
|
+
return existsSync(join(codexHomeDir(), "sessions"));
|
|
28308
|
+
},
|
|
28309
|
+
listSessions(opts = {}) {
|
|
28310
|
+
const root = join(codexHomeDir(), "sessions");
|
|
28311
|
+
if (!existsSync(root)) return [];
|
|
28312
|
+
const out = rolloutFiles(root).map(toSession);
|
|
28313
|
+
const since = opts.sinceMs !== void 0 ? Date.now() - opts.sinceMs : void 0;
|
|
28314
|
+
return out.filter((s) => since === void 0 || Date.parse(s.updatedAt) >= since).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
28315
|
+
},
|
|
28316
|
+
*readEvents(session) {
|
|
28317
|
+
let text;
|
|
28318
|
+
try {
|
|
28319
|
+
text = readFileSync(session.path, "utf8");
|
|
28320
|
+
} catch {
|
|
28321
|
+
return;
|
|
28322
|
+
}
|
|
28323
|
+
for (const raw of text.split("\n")) {
|
|
28324
|
+
if (!raw.trim()) continue;
|
|
28325
|
+
let e;
|
|
28326
|
+
try {
|
|
28327
|
+
e = JSON.parse(raw);
|
|
28328
|
+
} catch {
|
|
28329
|
+
continue;
|
|
28330
|
+
}
|
|
28331
|
+
yield* eventsOfEntry(session.id, e);
|
|
28332
|
+
}
|
|
28333
|
+
}
|
|
28334
|
+
};
|
|
28335
|
+
|
|
28336
|
+
//#endregion
|
|
28337
|
+
//#region src/harness/index.ts
|
|
28338
|
+
/**
|
|
28339
|
+
* index.ts — the adapter registry and the enabled set. Implementations are
|
|
28340
|
+
* listed here; which of them run is config (`harness.adapters` in the main
|
|
28341
|
+
* config, default ["claude-code"]), never a hardcoded enable list.
|
|
28342
|
+
*/
|
|
28343
|
+
/** Every implementation PAI knows, in display order. */
|
|
28344
|
+
const ADAPTERS = [claudeCodeAdapter, codexAdapter];
|
|
28345
|
+
/** Names enabled by config: `harness.adapters`, default ["claude-code"]. */
|
|
28346
|
+
function enabledHarnessNames(path = paiConfigFilePath()) {
|
|
28347
|
+
const list = effectiveMainConfig(path).harness?.adapters;
|
|
28348
|
+
if (!Array.isArray(list)) return ["claude-code"];
|
|
28349
|
+
return list.filter((n) => typeof n === "string");
|
|
28350
|
+
}
|
|
28351
|
+
|
|
28352
|
+
//#endregion
|
|
28353
|
+
//#region src/cli/commands/harness.ts
|
|
28354
|
+
/** `pai harness ...`: sessions PAI can read from agent harnesses (read-only). */
|
|
28355
|
+
const fmt = (iso) => Number.isFinite(Date.parse(iso)) ? new Date(iso).toISOString().slice(0, 16).replace("T", " ") : "—";
|
|
28356
|
+
function sessionsFor(harness, cwd) {
|
|
28357
|
+
const a = ADAPTERS.find((x) => x.name === harness);
|
|
28358
|
+
if (!a || !a.detect()) return [];
|
|
28359
|
+
return a.listSessions(cwd ? { cwd } : void 0);
|
|
28360
|
+
}
|
|
28361
|
+
function listCmd$1(opts) {
|
|
28362
|
+
const enabled = new Set(enabledHarnessNames());
|
|
28363
|
+
const rows = ADAPTERS.map((a) => {
|
|
28364
|
+
const detected = a.detect();
|
|
28365
|
+
return [
|
|
28366
|
+
a.name,
|
|
28367
|
+
detected ? ok$1("yes") : dim$1("no"),
|
|
28368
|
+
enabled.has(a.name) ? ok$1("yes") : dim$1("no"),
|
|
28369
|
+
detected ? String(sessionsFor(a.name).length) : dim$1("—")
|
|
28370
|
+
];
|
|
28371
|
+
});
|
|
28372
|
+
if (opts.json) {
|
|
28373
|
+
console.log(JSON.stringify(ADAPTERS.map((a) => ({
|
|
28374
|
+
adapter: a.name,
|
|
28375
|
+
detected: a.detect(),
|
|
28376
|
+
enabled: enabled.has(a.name),
|
|
28377
|
+
sessions: a.detect() ? sessionsFor(a.name).length : void 0
|
|
28378
|
+
})), null, 2));
|
|
28379
|
+
return 0;
|
|
28380
|
+
}
|
|
28381
|
+
console.log(renderTable$1([
|
|
28382
|
+
"adapter",
|
|
28383
|
+
"detected",
|
|
28384
|
+
"enabled",
|
|
28385
|
+
"sessions"
|
|
28386
|
+
], rows));
|
|
28387
|
+
console.log(dim$1(` enabled set comes from config key harness.adapters (pai config get harness.adapters)`));
|
|
28388
|
+
return 0;
|
|
28389
|
+
}
|
|
28390
|
+
function sessionsCmd(opts) {
|
|
28391
|
+
const names = opts.harness ? [opts.harness] : enabledHarnessNames();
|
|
28392
|
+
const out = [];
|
|
28393
|
+
for (const name of names) out.push(...sessionsFor(name, opts.cwd));
|
|
28394
|
+
out.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
28395
|
+
if (opts.json) {
|
|
28396
|
+
console.log(JSON.stringify(out, null, 2));
|
|
28397
|
+
return 0;
|
|
28398
|
+
}
|
|
28399
|
+
if (!out.length) {
|
|
28400
|
+
console.log(dim$1(` no sessions${opts.cwd ? ` under ${opts.cwd}` : ""} (${names.join(", ")})`));
|
|
28401
|
+
return 0;
|
|
28402
|
+
}
|
|
28403
|
+
console.log(renderTable$1([
|
|
28404
|
+
"harness",
|
|
28405
|
+
"id",
|
|
28406
|
+
"updated",
|
|
28407
|
+
"cwd"
|
|
28408
|
+
], out.map((s) => [
|
|
28409
|
+
s.harness,
|
|
28410
|
+
s.id,
|
|
28411
|
+
fmt(s.updatedAt),
|
|
28412
|
+
s.cwd ? maskSecrets(shortenPath(s.cwd, 48)) : dim$1("—")
|
|
28413
|
+
])));
|
|
28414
|
+
return 0;
|
|
28415
|
+
}
|
|
28416
|
+
const harnessCommand = {
|
|
28417
|
+
name: "harness",
|
|
28418
|
+
summary: "Sessions PAI can read from agent harnesses (Claude Code, Codex, ...)",
|
|
28419
|
+
description: "Read-only ingestion of other harnesses' session logs. `list` shows each adapter: detected (its store exists), enabled (config key harness.adapters), and its session count. `sessions` lists the sessions themselves.",
|
|
28420
|
+
examples: [
|
|
28421
|
+
"pai harness list",
|
|
28422
|
+
"pai harness sessions",
|
|
28423
|
+
"pai harness sessions --harness codex --cwd ~/proj"
|
|
28424
|
+
],
|
|
28425
|
+
seeAlso: ["pai config get harness.adapters"],
|
|
28426
|
+
subcommands: [{
|
|
28427
|
+
name: "list",
|
|
28428
|
+
summary: "Each adapter: detected, enabled, session count",
|
|
28429
|
+
description: "One row per adapter PAI knows. Only enabled adapters whose store exists are ever read.",
|
|
28430
|
+
options: [{
|
|
28431
|
+
name: "json",
|
|
28432
|
+
type: "boolean",
|
|
28433
|
+
default: false,
|
|
28434
|
+
summary: "Print JSON instead of a table"
|
|
28435
|
+
}],
|
|
28436
|
+
examples: ["pai harness list", "pai harness list --json"],
|
|
28437
|
+
run: (ctx) => listCmd$1({ json: ctx.opts.json === true })
|
|
28438
|
+
}, {
|
|
28439
|
+
name: "sessions",
|
|
28440
|
+
summary: "List sessions, newest first",
|
|
28441
|
+
description: "Sessions of the enabled adapters (or one harness with --harness), optionally limited to one working directory.",
|
|
28442
|
+
options: [
|
|
28443
|
+
{
|
|
28444
|
+
name: "harness",
|
|
28445
|
+
type: "string",
|
|
28446
|
+
value: "NAME",
|
|
28447
|
+
summary: "Only this adapter (default: the enabled set)"
|
|
28448
|
+
},
|
|
28449
|
+
{
|
|
28450
|
+
name: "cwd",
|
|
28451
|
+
type: "string",
|
|
28452
|
+
value: "DIR",
|
|
28453
|
+
summary: "Only sessions of this working directory"
|
|
28454
|
+
},
|
|
28455
|
+
{
|
|
28456
|
+
name: "json",
|
|
28457
|
+
type: "boolean",
|
|
28458
|
+
default: false,
|
|
28459
|
+
summary: "Print JSON instead of a table"
|
|
28460
|
+
}
|
|
28461
|
+
],
|
|
28462
|
+
examples: [
|
|
28463
|
+
"pai harness sessions",
|
|
28464
|
+
"pai harness sessions --harness codex",
|
|
28465
|
+
"pai harness sessions --cwd ~/proj"
|
|
28466
|
+
],
|
|
28467
|
+
run: (ctx) => {
|
|
28468
|
+
const cwd = ctx.opts.cwd;
|
|
28469
|
+
if (cwd && !existsSync(cwd)) {
|
|
28470
|
+
console.log(dim$1(` no such directory: ${cwd}`));
|
|
28471
|
+
return 0;
|
|
28472
|
+
}
|
|
28473
|
+
return sessionsCmd({
|
|
28474
|
+
harness: ctx.opts.harness,
|
|
28475
|
+
cwd,
|
|
28476
|
+
json: ctx.opts.json === true
|
|
28477
|
+
});
|
|
28478
|
+
}
|
|
28479
|
+
}]
|
|
28480
|
+
};
|
|
28481
|
+
|
|
28482
|
+
//#endregion
|
|
28483
|
+
//#region src/hooks/ts/lib/worker-git-guard.ts
|
|
28484
|
+
/** Splits on &&, ||, ;, | and newlines outside quotes. */
|
|
28485
|
+
function splitSegments(command) {
|
|
28486
|
+
const out = [];
|
|
28487
|
+
let cur = "";
|
|
28488
|
+
let quote = "";
|
|
28489
|
+
for (let i = 0; i < command.length; i++) {
|
|
28490
|
+
const c = command[i];
|
|
28491
|
+
if (quote) {
|
|
28492
|
+
if (c === quote) quote = "";
|
|
28493
|
+
cur += c;
|
|
28494
|
+
} else if (c === "\"" || c === "'") {
|
|
28495
|
+
quote = c;
|
|
28496
|
+
cur += c;
|
|
28497
|
+
} else if (c === "&" || c === "|" || c === ";" || c === "\n") {
|
|
28498
|
+
out.push(cur);
|
|
28499
|
+
cur = "";
|
|
28500
|
+
} else cur += c;
|
|
28501
|
+
}
|
|
28502
|
+
out.push(cur);
|
|
28503
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
28504
|
+
}
|
|
28505
|
+
|
|
28506
|
+
//#endregion
|
|
28507
|
+
//#region src/hooks/ts/lib/rules/eval.ts
|
|
28508
|
+
/**
|
|
28509
|
+
* eval.ts — run rules against a tool call.
|
|
28510
|
+
*
|
|
28511
|
+
* Order is fixed: the first block hit wins, else the first confirm hit, else
|
|
28512
|
+
* the first warn hit. Text inside quotes is not a command, so rules test the
|
|
28513
|
+
* content with quoted strings removed; rules marked `quoted: true` also test
|
|
28514
|
+
* it with only the quote marks removed (as detectAttack did before);
|
|
28515
|
+
* confirm rules additionally test per-segment, so a quoted `git push -f` in a
|
|
28516
|
+
* commit message stays inert while a quoted `+main` refspec still counts.
|
|
28517
|
+
*/
|
|
28518
|
+
/** Quoted strings removed: text inside a commit message or echo is not a command. */
|
|
28519
|
+
function dropQuoted(segment) {
|
|
28520
|
+
return segment.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, "\"\"");
|
|
28521
|
+
}
|
|
28522
|
+
/** A quoted +refspec is still a forced push, so push segments also test with only the quote marks removed. */
|
|
28523
|
+
function gitPatternHit(pattern, segment) {
|
|
28524
|
+
const bare = dropQuoted(segment);
|
|
28525
|
+
if (pattern.test(bare)) return true;
|
|
28526
|
+
return /\bgit\b.*\spush\b/.test(bare) && pattern.test(segment.replace(/["']/g, ""));
|
|
28527
|
+
}
|
|
28528
|
+
const regexCache = /* @__PURE__ */ new Map();
|
|
28529
|
+
function ruleRegex(rule) {
|
|
28530
|
+
if (!rule.match) return null;
|
|
28531
|
+
const key = `${rule.match} ${rule.flags ?? ""}`;
|
|
28532
|
+
const cached = regexCache.get(key);
|
|
28533
|
+
if (cached) return cached;
|
|
28534
|
+
try {
|
|
28535
|
+
const re = new RegExp(rule.match, rule.flags ?? "");
|
|
28536
|
+
regexCache.set(key, re);
|
|
28537
|
+
return re;
|
|
28538
|
+
} catch {
|
|
28539
|
+
return null;
|
|
28540
|
+
}
|
|
28541
|
+
}
|
|
28542
|
+
function toolMatches(rule, toolName) {
|
|
28543
|
+
if (!rule.tool) return toolName === "Bash";
|
|
28544
|
+
try {
|
|
28545
|
+
return new RegExp(rule.tool).test(toolName);
|
|
28546
|
+
} catch {
|
|
28547
|
+
return false;
|
|
28548
|
+
}
|
|
28549
|
+
}
|
|
28550
|
+
/** First block hit, else first confirm hit, else first warn hit; null when none. */
|
|
28551
|
+
function evaluateRules(rules, toolName, content) {
|
|
28552
|
+
const unquoted = content.replace(/["']/g, "");
|
|
28553
|
+
const bare = dropQuoted(content);
|
|
28554
|
+
const segments = splitSegments(content);
|
|
28555
|
+
for (const severity of [
|
|
28556
|
+
"block",
|
|
28557
|
+
"confirm",
|
|
28558
|
+
"warn"
|
|
28559
|
+
]) for (const rule of rules) {
|
|
28560
|
+
if (rule.severity !== severity || !toolMatches(rule, toolName)) continue;
|
|
28561
|
+
const re = ruleRegex(rule);
|
|
28562
|
+
if (!re) continue;
|
|
28563
|
+
if (severity === "confirm" ? segments.some((seg) => gitPatternHit(re, seg)) : re.test(bare) || rule.quoted === true && (re.test(content) || re.test(unquoted))) return { rule };
|
|
28564
|
+
}
|
|
28565
|
+
return null;
|
|
28566
|
+
}
|
|
28567
|
+
|
|
28568
|
+
//#endregion
|
|
28569
|
+
//#region src/hooks/ts/lib/rules/lint.ts
|
|
28570
|
+
/**
|
|
28571
|
+
* lint.ts — run each rule's own fixtures.
|
|
28572
|
+
*
|
|
28573
|
+
* Every `tests.match` must hit the rule, every `tests.no_match` must not.
|
|
28574
|
+
* A single-step rule's fixtures are plain strings tested as Bash commands; a
|
|
28575
|
+
* sequence rule's are lists of step contents, checked step by step (the
|
|
28576
|
+
* window and ordering are the scan's business, not the fixtures'). A rule
|
|
28577
|
+
* with no fixtures is ok (nothing to check).
|
|
28578
|
+
*/
|
|
28579
|
+
function stepRegex(step) {
|
|
28580
|
+
try {
|
|
28581
|
+
return new RegExp(step.match, step.flags ?? "");
|
|
28582
|
+
} catch {
|
|
28583
|
+
return null;
|
|
28584
|
+
}
|
|
28585
|
+
}
|
|
28586
|
+
/**
|
|
28587
|
+
* A fixture sequence hits when every step's regex matches its content and its
|
|
28588
|
+
* tool pin, when set. A plain string is a Bash-shaped step; an object names
|
|
28589
|
+
* the tool, so a rule whose step is pinned to a fetched result (WebFetch,
|
|
28590
|
+
* Read) can still carry a fixture that hits. A step whose source
|
|
28591
|
+
* backreferences an earlier step's group (`\1`) is checked with that group's
|
|
28592
|
+
* capture substituted in — the same resolution the transcript scan applies —
|
|
28593
|
+
* so a fixture like download-then-execute's "same file twice" checks the way
|
|
28594
|
+
* the scan does.
|
|
28595
|
+
*/
|
|
28596
|
+
function sequenceHit(rule, steps) {
|
|
28597
|
+
const seqSteps = rule.sequence?.steps ?? [];
|
|
28598
|
+
if (steps.length !== seqSteps.length) return false;
|
|
28599
|
+
const prior = [];
|
|
28600
|
+
for (let i = 0; i < seqSteps.length; i++) {
|
|
28601
|
+
const step = seqSteps[i];
|
|
28602
|
+
const { tool, content } = steps[i];
|
|
28603
|
+
if (step.tool && !new RegExp(step.tool).test(tool ?? "Bash")) return false;
|
|
28604
|
+
if (step.match.includes("\\1") && prior.length) {
|
|
28605
|
+
const cap = prior[0][1];
|
|
28606
|
+
if (!cap || !new RegExp(step.match.replace(/\\1/g, cap), step.flags ?? "").test(content)) return false;
|
|
28607
|
+
continue;
|
|
28608
|
+
}
|
|
28609
|
+
const re = stepRegex(step);
|
|
28610
|
+
if (!re) return false;
|
|
28611
|
+
const m = content.match(re);
|
|
28612
|
+
if (!m) return false;
|
|
28613
|
+
prior.push(m);
|
|
28614
|
+
}
|
|
28615
|
+
return true;
|
|
28616
|
+
}
|
|
28617
|
+
function asFixtureSteps(t) {
|
|
28618
|
+
if (!Array.isArray(t)) return null;
|
|
28619
|
+
return t.map((s) => typeof s === "string" ? { content: s } : {
|
|
28620
|
+
tool: s.tool,
|
|
28621
|
+
content: s.content
|
|
28622
|
+
});
|
|
28623
|
+
}
|
|
28624
|
+
function lintRules(rules) {
|
|
28625
|
+
return rules.map((rule) => {
|
|
28626
|
+
const failures = [];
|
|
28627
|
+
const show = (t) => Array.isArray(t) ? t.map((s) => typeof s === "string" ? s : `${s.tool ?? "Bash"}: ${s.content}`).join(" | ") : t;
|
|
28628
|
+
if (rule.sequence) {
|
|
28629
|
+
for (const t of rule.tests?.match ?? []) {
|
|
28630
|
+
const steps = asFixtureSteps(t);
|
|
28631
|
+
if (!steps || !sequenceHit(rule, steps)) failures.push(`tests.match did not hit: ${show(t)}`);
|
|
28632
|
+
}
|
|
28633
|
+
for (const t of rule.tests?.no_match ?? []) {
|
|
28634
|
+
const steps = asFixtureSteps(t);
|
|
28635
|
+
if (steps && sequenceHit(rule, steps)) failures.push(`tests.no_match hit: ${show(t)}`);
|
|
28636
|
+
}
|
|
28637
|
+
return {
|
|
28638
|
+
id: rule.id,
|
|
28639
|
+
ok: failures.length === 0,
|
|
28640
|
+
failures
|
|
28641
|
+
};
|
|
28642
|
+
}
|
|
28643
|
+
for (const text of rule.tests?.match ?? []) {
|
|
28644
|
+
const hit = evaluateRules([rule], "Bash", text);
|
|
28645
|
+
if (!hit || hit.rule.id !== rule.id) failures.push(`tests.match did not hit: ${text}`);
|
|
28646
|
+
}
|
|
28647
|
+
for (const text of rule.tests?.no_match ?? []) {
|
|
28648
|
+
const hit = evaluateRules([rule], "Bash", text);
|
|
28649
|
+
if (hit && hit.rule.id === rule.id) failures.push(`tests.no_match hit: ${text}`);
|
|
28650
|
+
}
|
|
28651
|
+
return {
|
|
28652
|
+
id: rule.id,
|
|
28653
|
+
ok: failures.length === 0,
|
|
28654
|
+
failures
|
|
28655
|
+
};
|
|
28656
|
+
});
|
|
28657
|
+
}
|
|
28658
|
+
|
|
28659
|
+
//#endregion
|
|
28660
|
+
//#region src/cli/commands/rules.ts
|
|
28661
|
+
function listCmd(json) {
|
|
28662
|
+
const { rules, errors } = listRules(userRuleDirs(process.cwd()));
|
|
28663
|
+
if (json) {
|
|
28664
|
+
console.log(JSON.stringify({
|
|
28665
|
+
rules,
|
|
28666
|
+
errors
|
|
28667
|
+
}, null, 2));
|
|
28668
|
+
return 0;
|
|
28669
|
+
}
|
|
28670
|
+
console.log(renderTable$1([
|
|
28671
|
+
"id",
|
|
28672
|
+
"severity",
|
|
28673
|
+
"source",
|
|
28674
|
+
"enabled"
|
|
28675
|
+
], rules.map((r) => [
|
|
28676
|
+
r.id,
|
|
28677
|
+
r.severity,
|
|
28678
|
+
r.source,
|
|
28679
|
+
r.severity === "off" ? "off" : "on"
|
|
28680
|
+
])));
|
|
28681
|
+
for (const e of errors) console.log(err(`load error: ${e}`));
|
|
28682
|
+
return 0;
|
|
28683
|
+
}
|
|
28684
|
+
function lintCmd(json) {
|
|
28685
|
+
const { rules, errors } = listRules(userRuleDirs(process.cwd()));
|
|
28686
|
+
const enabled = rules.filter((r) => r.severity !== "off");
|
|
28687
|
+
const lints = lintRules(enabled);
|
|
28688
|
+
const failed = lints.filter((l) => !l.ok);
|
|
28689
|
+
if (json) {
|
|
28690
|
+
console.log(JSON.stringify({
|
|
28691
|
+
lints,
|
|
28692
|
+
errors
|
|
28693
|
+
}, null, 2));
|
|
28694
|
+
return failed.length || errors.length ? 1 : 0;
|
|
28695
|
+
}
|
|
28696
|
+
for (const l of lints) {
|
|
28697
|
+
if (l.ok) continue;
|
|
28698
|
+
console.log(err(`✖ ${l.id}`));
|
|
28699
|
+
for (const f of l.failures) console.log(` ${f}`);
|
|
28700
|
+
}
|
|
28701
|
+
for (const e of errors) console.log(err(`load error: ${e}`));
|
|
28702
|
+
if (!failed.length && !errors.length) {
|
|
28703
|
+
console.log(ok$1(`✔ ${enabled.length} rules, fixtures pass`));
|
|
28704
|
+
return 0;
|
|
28705
|
+
}
|
|
28706
|
+
return 1;
|
|
28707
|
+
}
|
|
28708
|
+
const rulesCommand = {
|
|
28709
|
+
name: "rules",
|
|
28710
|
+
summary: "Security rules: list them, lint their fixtures",
|
|
28711
|
+
description: "The PreToolUse security rules. Built-ins ship with the package; user rules live in PAI_HOME/rules or .pai/rules as *.rule.yaml files — a user rule with a built-in's id replaces it, severity: off disables it.",
|
|
28712
|
+
examples: ["pai rules list", "pai rules lint"],
|
|
28713
|
+
seeAlso: ["pai audit tokens hooks"],
|
|
28714
|
+
subcommands: [{
|
|
28715
|
+
name: "list",
|
|
28716
|
+
summary: "Every rule with severity, source and enabled state",
|
|
28717
|
+
description: "One row per rule id; a disabled (severity: off) rule shows as off.",
|
|
28718
|
+
options: [{
|
|
28719
|
+
name: "json",
|
|
28720
|
+
type: "boolean",
|
|
28721
|
+
default: false,
|
|
28722
|
+
summary: "Machine-readable JSON output"
|
|
28723
|
+
}],
|
|
28724
|
+
examples: ["pai rules list", "pai rules list --json"],
|
|
28725
|
+
run: (ctx) => listCmd(ctx.opts.json === true)
|
|
28726
|
+
}, {
|
|
28727
|
+
name: "lint",
|
|
28728
|
+
summary: "Run every rule's fixtures; exit 1 on a failure or load error",
|
|
28729
|
+
description: "Each rule's tests.match strings must hit and tests.no_match strings must not. Load errors count as failures.",
|
|
28730
|
+
options: [{
|
|
28731
|
+
name: "json",
|
|
28732
|
+
type: "boolean",
|
|
28733
|
+
default: false,
|
|
28734
|
+
summary: "Machine-readable JSON output"
|
|
28735
|
+
}],
|
|
28736
|
+
examples: ["pai rules lint", "pai rules lint --json"],
|
|
28737
|
+
run: (ctx) => lintCmd(ctx.opts.json === true)
|
|
28738
|
+
}]
|
|
28739
|
+
};
|
|
28740
|
+
|
|
28741
|
+
//#endregion
|
|
28742
|
+
//#region src/hooks/ts/lib/transcript-text.ts
|
|
28743
|
+
/**
|
|
28744
|
+
* transcript-text.ts — small, pure pieces of transcript parsing that are
|
|
28745
|
+
* worth testing in isolation.
|
|
28746
|
+
*
|
|
28747
|
+
* Split out of context-compression-hook.ts (a hook entrypoint that calls
|
|
28748
|
+
* `main()` and `process.exit()` at import time, which makes it unsafe to
|
|
28749
|
+
* import directly from a test) so the two bug fixes that live here can be
|
|
28750
|
+
* covered by a real regression test rather than only a manual before/after
|
|
28751
|
+
* digest.
|
|
28752
|
+
*/
|
|
28753
|
+
/**
|
|
28754
|
+
* Turn Claude content (string or content block array) into plain text.
|
|
28755
|
+
*
|
|
28756
|
+
* BUG (found in ~1 in 4 injected digests on this machine): a "user" turn is
|
|
28757
|
+
* not always something the user typed — a tool_result is also delivered as a
|
|
28758
|
+
* user-role message, and its `content` block looks like
|
|
28759
|
+
* `{ type: "tool_result", content: [...] }` where the inner `content` is
|
|
28760
|
+
* itself an array of blocks, not a string. The old code fell back to
|
|
28761
|
+
* `String(c.content)` for anything without a `.text` field, and `String()`
|
|
28762
|
+
* on an array of objects produces literal `"[object Object],[object
|
|
28763
|
+
* Object]"` — which is exactly what showed up under "Recent user requests".
|
|
28764
|
+
* Only ever take the `.text` of a `type: "text"` block; everything else
|
|
28765
|
+
* (tool_use, tool_result, image, and any nested content) is ignored rather
|
|
28766
|
+
* than stringified.
|
|
28767
|
+
*/
|
|
28768
|
+
function contentToText(content) {
|
|
28769
|
+
if (typeof content === "string") return content;
|
|
28770
|
+
if (Array.isArray(content)) return content.map((c) => {
|
|
28771
|
+
if (typeof c === "string") return c;
|
|
28772
|
+
if (c && typeof c === "object" && c.type === "text" && typeof c.text === "string") return c.text;
|
|
28773
|
+
return "";
|
|
28774
|
+
}).join(" ").trim();
|
|
28775
|
+
return "";
|
|
28776
|
+
}
|
|
28777
|
+
|
|
28778
|
+
//#endregion
|
|
28779
|
+
//#region src/session/handoff.ts
|
|
28780
|
+
/**
|
|
28781
|
+
* handoff.ts — a portable, bounded, redacted handoff brief for one session.
|
|
28782
|
+
*
|
|
28783
|
+
* `pai handoff export` scrapes a Claude Code transcript into a short Markdown
|
|
28784
|
+
* brief a successor session can continue from: what was asked, what changed,
|
|
28785
|
+
* what ran, and where it stopped. Unlike the model-written context handover
|
|
28786
|
+
* this is mechanical — no model call, no prose to hallucinate — and it is
|
|
28787
|
+
* redacted (secrets replaced by [REDACTED]) before it is bounded, so a brief
|
|
28788
|
+
* can be pasted, mailed or committed without leaking a token.
|
|
28789
|
+
*
|
|
28790
|
+
* The brief is assembled as sections, masked, then cut to `maxBytes` from the
|
|
28791
|
+
* oldest end (Recent commands first, then Last steps) with a marker line, so
|
|
28792
|
+
* the parts a successor needs most (Session, requests, files) survive first.
|
|
28793
|
+
*/
|
|
28794
|
+
/** Default brief size: comfortably under what a successor can read in one turn. */
|
|
28795
|
+
const HANDOFF_MAX_BYTES = 49152;
|
|
28796
|
+
/** Most distinct files listed under "Files changed". */
|
|
28797
|
+
const MAX_FILES = 50;
|
|
28798
|
+
/** Most Bash commands listed under "Recent commands". */
|
|
28799
|
+
const MAX_COMMANDS = 20;
|
|
28800
|
+
/** Most assistant text messages listed under "Last steps". */
|
|
28801
|
+
const MAX_STEPS = 10;
|
|
28802
|
+
/** Each "Last steps" entry is truncated to this many characters. */
|
|
28803
|
+
const STEP_CHARS = 400;
|
|
28804
|
+
/** Tools whose input carries a changed file. */
|
|
28805
|
+
const FILE_TOOLS = new Set([
|
|
28806
|
+
"Edit",
|
|
28807
|
+
"Write",
|
|
28808
|
+
"MultiEdit"
|
|
28809
|
+
]);
|
|
28810
|
+
/** The last assistant text message of a transcript, or null when there is none. */
|
|
28811
|
+
function lastAssistantText(entries) {
|
|
28812
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
28813
|
+
const entry = entries[i];
|
|
28814
|
+
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue;
|
|
28815
|
+
const text = entry.message.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n").trim();
|
|
28816
|
+
if (text) return text;
|
|
28817
|
+
}
|
|
28818
|
+
return null;
|
|
28819
|
+
}
|
|
28820
|
+
/** First user text message that looks like something typed, not an injected block. */
|
|
28821
|
+
function firstUserText(entries) {
|
|
28822
|
+
for (const entry of entries) {
|
|
28823
|
+
if (entry.type !== "user") continue;
|
|
28824
|
+
const content = entry.message?.content;
|
|
28825
|
+
const text = typeof content === "string" ? content : contentToText(content);
|
|
28826
|
+
if (text.trim()) return text.trim();
|
|
28827
|
+
}
|
|
28828
|
+
return null;
|
|
28829
|
+
}
|
|
28830
|
+
/** Last user text message — the request the session was working on. */
|
|
28831
|
+
function lastUserText(entries) {
|
|
28832
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
28833
|
+
const entry = entries[i];
|
|
28834
|
+
if (entry.type !== "user") continue;
|
|
28835
|
+
const content = entry.message?.content;
|
|
28836
|
+
const text = typeof content === "string" ? content : contentToText(content);
|
|
28837
|
+
if (text.trim()) return text.trim();
|
|
28838
|
+
}
|
|
28839
|
+
return null;
|
|
28840
|
+
}
|
|
28841
|
+
/**
|
|
28842
|
+
* Exit code of a Bash tool_result, when the transcript records one.
|
|
28843
|
+
* Claude Code serialises Bash results as plain text; an interrupted command
|
|
28844
|
+
* reads "Exit code N", and a JSON payload carries `interrupted`/`success`.
|
|
28845
|
+
*/
|
|
28846
|
+
function bashExitCode(result) {
|
|
28847
|
+
if (result.is_error === true) return null;
|
|
28848
|
+
const text = contentToText(result.content);
|
|
28849
|
+
const m = text.match(/Exit code (\d+)/);
|
|
28850
|
+
if (m) return Number(m[1]);
|
|
28851
|
+
try {
|
|
28852
|
+
const parsed = JSON.parse(text);
|
|
28853
|
+
if (parsed.interrupted === true) return null;
|
|
28854
|
+
if (parsed.success === false) return null;
|
|
28855
|
+
} catch {}
|
|
28856
|
+
return 0;
|
|
28857
|
+
}
|
|
28858
|
+
/**
|
|
28859
|
+
* Build the handoff brief for one transcript: Markdown, secrets masked,
|
|
28860
|
+
* cut to `maxBytes` whole sections from the oldest end with a marker line.
|
|
28861
|
+
*
|
|
28862
|
+
* @param transcriptPath a Claude Code session transcript (.jsonl)
|
|
28863
|
+
*/
|
|
28864
|
+
function buildHandoffBrief(transcriptPath, opts = {}) {
|
|
28865
|
+
const maxBytes = opts.maxBytes ?? HANDOFF_MAX_BYTES;
|
|
28866
|
+
const raw = readFileSync(transcriptPath, "utf8");
|
|
28867
|
+
const entries = [];
|
|
28868
|
+
for (const line of raw.split("\n")) {
|
|
28869
|
+
if (!line.trim()) continue;
|
|
28870
|
+
try {
|
|
28871
|
+
entries.push(JSON.parse(line));
|
|
28872
|
+
} catch {}
|
|
28873
|
+
}
|
|
28874
|
+
const sessionId = basename(transcriptPath).replace(/\.jsonl$/, "") || "unknown";
|
|
28875
|
+
const cwd = entries.find((e) => e.cwd)?.cwd ?? "";
|
|
28876
|
+
const first = entries.find((e) => e.timestamp)?.timestamp ?? "";
|
|
28877
|
+
const last = [...entries].reverse().find((e) => e.timestamp)?.timestamp ?? "";
|
|
28878
|
+
const files = [];
|
|
28879
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28880
|
+
const commands = [];
|
|
28881
|
+
const steps = [];
|
|
28882
|
+
const results = /* @__PURE__ */ new Map();
|
|
28883
|
+
for (const entry of entries) {
|
|
28884
|
+
const content = entry.message?.content;
|
|
28885
|
+
if (!Array.isArray(content)) continue;
|
|
28886
|
+
for (const part of content) if (part?.type === "tool_result" && typeof part.tool_use_id === "string") results.set(part.tool_use_id, part);
|
|
28887
|
+
}
|
|
28888
|
+
for (const entry of entries) {
|
|
28889
|
+
const content = entry.message?.content;
|
|
28890
|
+
if (!Array.isArray(content)) continue;
|
|
28891
|
+
for (const part of content) if (part?.type === "tool_use" && part.name && FILE_TOOLS.has(part.name)) {
|
|
28892
|
+
const path = part.input?.file_path;
|
|
28893
|
+
if (typeof path === "string" && !seen.has(path)) {
|
|
28894
|
+
seen.add(path);
|
|
28895
|
+
files.push(path);
|
|
28896
|
+
}
|
|
28897
|
+
} else if (part?.type === "tool_use" && part.name === "Bash") {
|
|
28898
|
+
const command = typeof part.input?.command === "string" ? part.input.command : "";
|
|
28899
|
+
if (command) {
|
|
28900
|
+
const code = part.id ? results.get(part.id) : void 0;
|
|
28901
|
+
const exit = code ? bashExitCode(code) : null;
|
|
28902
|
+
commands.push(exit === null ? command : `${command} [exit ${exit}]`);
|
|
28903
|
+
}
|
|
28904
|
+
} else if (part?.type === "text" && typeof part.text === "string" && entry.type === "assistant") {
|
|
28905
|
+
const text = part.text.trim();
|
|
28906
|
+
if (text) steps.push(text.length > STEP_CHARS ? `${text.slice(0, STEP_CHARS)}…` : text);
|
|
28907
|
+
}
|
|
28908
|
+
}
|
|
28909
|
+
const sections = {
|
|
28910
|
+
Session: [
|
|
28911
|
+
`- id: ${sessionId}`,
|
|
28912
|
+
`- cwd: ${cwd}`,
|
|
28913
|
+
`- start: ${first}`,
|
|
28914
|
+
`- end: ${last}`
|
|
28915
|
+
].join("\n"),
|
|
28916
|
+
"First request": firstUserText(entries) ?? "(none)",
|
|
28917
|
+
"Latest request": lastUserText(entries) ?? "(none)",
|
|
28918
|
+
"Files changed": files.slice(0, MAX_FILES).map((f) => `- ${f}`).join("\n") || "(none)",
|
|
28919
|
+
"Recent commands": commands.slice(-MAX_COMMANDS).map((c) => `- ${c}`).join("\n") || "(none)",
|
|
28920
|
+
"Last steps": steps.slice(-MAX_STEPS).map((s) => `- ${s}`).join("\n") || "(none)",
|
|
28921
|
+
"Where it stopped": lastAssistantText(entries) ?? "(no assistant message)"
|
|
28922
|
+
};
|
|
28923
|
+
const masked = maskSecrets(Object.entries(sections).map(([title, body]) => `## ${title}\n${body}`).join("\n\n"));
|
|
28924
|
+
if (Buffer.byteLength(masked, "utf8") <= maxBytes) return masked;
|
|
28925
|
+
const note = `\n\n[truncated to ${maxBytes} bytes]`;
|
|
28926
|
+
const budget = maxBytes - Buffer.byteLength(note, "utf8");
|
|
28927
|
+
const render = (parts) => Object.entries(parts).map(([title, body]) => `## ${title}\n${body}`).join("\n\n");
|
|
28928
|
+
const order = ["Recent commands", "Last steps"];
|
|
28929
|
+
const kept = { ...sections };
|
|
28930
|
+
for (const name of order) {
|
|
28931
|
+
delete kept[name];
|
|
28932
|
+
const text = render(kept);
|
|
28933
|
+
if (Buffer.byteLength(text, "utf8") <= budget) return text + note;
|
|
28934
|
+
}
|
|
28935
|
+
kept["Where it stopped"] = (kept["Where it stopped"] ?? "").split("\n")[0].slice(0, 200);
|
|
28936
|
+
const text = render(kept);
|
|
28937
|
+
if (Buffer.byteLength(text, "utf8") <= budget) return text + note;
|
|
28938
|
+
return Buffer.from(text, "utf8").subarray(0, Math.max(0, budget)).toString("utf8") + note;
|
|
28939
|
+
}
|
|
28940
|
+
/** `yyyymmdd-hhmm` in local time, for the handoff file name. */
|
|
28941
|
+
function stamp(d = /* @__PURE__ */ new Date()) {
|
|
28942
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
28943
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
28944
|
+
}
|
|
28945
|
+
/** The handoffs directory under PAI_HOME. */
|
|
28946
|
+
function handoffsDir() {
|
|
28947
|
+
return paiHomePath("handoffs");
|
|
28948
|
+
}
|
|
28949
|
+
/**
|
|
28950
|
+
* Write a handoff brief into <PAI_HOME>/handoffs/<sessionId>-<yyyymmdd-hhmm>.md
|
|
28951
|
+
* (dir 0700, file 0600) and return its path.
|
|
28952
|
+
*/
|
|
28953
|
+
function writeHandoff(brief, sessionId) {
|
|
28954
|
+
const dir = handoffsDir();
|
|
28955
|
+
mkdirSync(dir, {
|
|
28956
|
+
recursive: true,
|
|
28957
|
+
mode: 448
|
|
28958
|
+
});
|
|
28959
|
+
const path = join(dir, `${sessionId}-${stamp()}.md`);
|
|
28960
|
+
writeFileSync(path, brief, { mode: 384 });
|
|
28961
|
+
return path;
|
|
28962
|
+
}
|
|
28963
|
+
/** Newest transcript of a cwd, newest first; null when the project has none. */
|
|
28964
|
+
function sessionTranscripts(cwd) {
|
|
28965
|
+
const dir = join(claudeProjectsDir(), encodeDir(cwd));
|
|
28966
|
+
let names;
|
|
28967
|
+
try {
|
|
28968
|
+
names = readdirSync(dir);
|
|
28969
|
+
} catch {
|
|
28970
|
+
return [];
|
|
28971
|
+
}
|
|
28972
|
+
return names.filter((n) => n.endsWith(".jsonl")).map((n) => join(dir, n)).map((path) => ({
|
|
28973
|
+
path,
|
|
28974
|
+
mtime: safeMtime(path)
|
|
28975
|
+
})).filter((f) => f.mtime !== null).sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
|
28976
|
+
}
|
|
28977
|
+
function safeMtime(path) {
|
|
28978
|
+
try {
|
|
28979
|
+
return statSync(path).mtimeMs;
|
|
28980
|
+
} catch {
|
|
28981
|
+
return null;
|
|
28982
|
+
}
|
|
28983
|
+
}
|
|
28984
|
+
/** Resolve a session id (or transcript path) in a cwd to its transcript path. */
|
|
28985
|
+
function resolveTranscript(session, cwd) {
|
|
28986
|
+
if (session && session.endsWith(".jsonl")) return session;
|
|
28987
|
+
const dir = join(claudeProjectsDir(), encodeDir(cwd));
|
|
28988
|
+
if (session) {
|
|
28989
|
+
const direct = join(dir, `${session}.jsonl`);
|
|
28990
|
+
try {
|
|
28991
|
+
statSync(direct);
|
|
28992
|
+
return direct;
|
|
28993
|
+
} catch {
|
|
28994
|
+
const archived = join(dir, "sessions", `${session}.jsonl`);
|
|
28995
|
+
try {
|
|
28996
|
+
statSync(archived);
|
|
28997
|
+
return archived;
|
|
28998
|
+
} catch {
|
|
28999
|
+
return null;
|
|
29000
|
+
}
|
|
29001
|
+
}
|
|
29002
|
+
}
|
|
29003
|
+
return sessionTranscripts(cwd)[0] ?? null;
|
|
29004
|
+
}
|
|
29005
|
+
/** The session id a transcript path carries (its file name). */
|
|
29006
|
+
function sessionIdOf(transcriptPath) {
|
|
29007
|
+
return basename(transcriptPath).replace(/\.jsonl$/, "");
|
|
29008
|
+
}
|
|
29009
|
+
|
|
29010
|
+
//#endregion
|
|
29011
|
+
//#region src/cli/commands/handoff.ts
|
|
29012
|
+
/** `pai handoff ...`: export a session's work as a portable, redacted brief. */
|
|
29013
|
+
function exportCmd(ctx) {
|
|
29014
|
+
const cwd = process.cwd();
|
|
29015
|
+
const session = ctx.args.SESSION;
|
|
29016
|
+
const transcript = resolveTranscript(session, cwd);
|
|
29017
|
+
if (!transcript) throw new CliError(session ? `no transcript for session ${session} in ${cwd}` : `no session transcripts in ${cwd}`, "pai handoff export <SESSION> — or run it inside the project the session worked in");
|
|
29018
|
+
const sessionId = sessionIdOf(transcript);
|
|
29019
|
+
const path = writeHandoff(buildHandoffBrief(transcript), sessionId);
|
|
29020
|
+
console.log(path);
|
|
29021
|
+
console.log(`Continue with: claude "Read ${path} and continue. [pai-handoff from=${sessionId}]"`);
|
|
29022
|
+
return 0;
|
|
29023
|
+
}
|
|
29024
|
+
function showCmd(ctx) {
|
|
29025
|
+
const path = ctx.args.PATH;
|
|
29026
|
+
try {
|
|
29027
|
+
process.stdout.write(readFileSync(path, "utf8"));
|
|
29028
|
+
} catch {
|
|
29029
|
+
throw new CliError(`cannot read ${path}`, "pai handoff export first, then pai handoff show <PATH>");
|
|
29030
|
+
}
|
|
29031
|
+
return 0;
|
|
29032
|
+
}
|
|
29033
|
+
const handoffCommand = {
|
|
29034
|
+
name: "handoff",
|
|
29035
|
+
summary: "Carry a session's work to a new one",
|
|
29036
|
+
description: "Exports a session transcript as a short, redacted Markdown brief (requests, files changed, recent commands, where it stopped) and prints the line to continue with. The brief never goes on the command line — only its path does. A `[pai-handoff from=<id>]` marker in the next session's first prompt is recorded in the handoff link log.",
|
|
29037
|
+
examples: [
|
|
29038
|
+
"pai handoff export",
|
|
29039
|
+
"pai handoff export 2026-10-22-session-id",
|
|
29040
|
+
"pai handoff show ~/.pai/handoffs/abc-20261022-1430.md"
|
|
29041
|
+
],
|
|
29042
|
+
seeAlso: ["pai audit tokens session"],
|
|
29043
|
+
subcommands: [{
|
|
29044
|
+
name: "export",
|
|
29045
|
+
summary: "Write the brief for a session (default: newest here)",
|
|
29046
|
+
description: "Prints the brief's path and the one-line continue hint. Secrets in the transcript are replaced with [REDACTED]; the brief is bounded to 48 KB.",
|
|
29047
|
+
args: [{
|
|
29048
|
+
name: "SESSION",
|
|
29049
|
+
summary: "Session id or transcript path (default: newest transcript of the current directory)"
|
|
29050
|
+
}],
|
|
29051
|
+
examples: ["pai handoff export", "pai handoff export 2026-10-22-session-id"],
|
|
29052
|
+
run: exportCmd
|
|
29053
|
+
}, {
|
|
29054
|
+
name: "show",
|
|
29055
|
+
summary: "Print a handoff brief",
|
|
29056
|
+
description: "Prints a brief written by `pai handoff export`.",
|
|
29057
|
+
args: [{
|
|
29058
|
+
name: "PATH",
|
|
29059
|
+
required: true,
|
|
29060
|
+
summary: "Brief file"
|
|
29061
|
+
}],
|
|
29062
|
+
examples: ["pai handoff show ~/.pai/handoffs/abc-20261022-1430.md"],
|
|
29063
|
+
run: showCmd
|
|
27230
29064
|
}]
|
|
27231
29065
|
};
|
|
27232
29066
|
|
|
@@ -27290,7 +29124,8 @@ const GROUPS = [
|
|
|
27290
29124
|
endTop,
|
|
27291
29125
|
resumeTop,
|
|
27292
29126
|
clearNamesCmd,
|
|
27293
|
-
sessionsCommand
|
|
29127
|
+
sessionsCommand,
|
|
29128
|
+
handoffCommand
|
|
27294
29129
|
]
|
|
27295
29130
|
},
|
|
27296
29131
|
{
|
|
@@ -27328,9 +29163,10 @@ const GROUPS = [
|
|
|
27328
29163
|
]
|
|
27329
29164
|
}))
|
|
27330
29165
|
];
|
|
27331
|
-
GROUPS.find((g) => g.id === "memory").commands.push(memoryCommand, kgCommand, zettelCommand, observationCommand, obsidianCommand, skillCommand);
|
|
29166
|
+
GROUPS.find((g) => g.id === "memory").commands.push(memoryCommand, kgCommand, zettelCommand, observationCommand, obsidianCommand, skillCommand, lessonsCommand);
|
|
27332
29167
|
GROUPS.find((g) => g.id === "workers").commands.push(modelsCommand);
|
|
27333
|
-
GROUPS.find((g) => g.id === "daemon").commands.push(auditCommand, reloadCommand);
|
|
29168
|
+
GROUPS.find((g) => g.id === "daemon").commands.push(auditCommand, harnessCommand, reloadCommand, rulesCommand);
|
|
29169
|
+
GROUPS.find((g) => g.id === "memory").commands.push(gitCommand);
|
|
27334
29170
|
|
|
27335
29171
|
//#endregion
|
|
27336
29172
|
//#region src/cli/index.ts
|