tokentracker-cli 0.87.7 → 0.87.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/dashboard/dist/assets/{AchievementBadge-4SSFyzNE.js → AchievementBadge-CpxX02OK.js} +1 -1
  4. package/dashboard/dist/assets/{AchievementsPage-4ASCXja2.js → AchievementsPage-CSms8IjI.js} +2 -2
  5. package/dashboard/dist/assets/{AchievementsSection-CyaMYMol.js → AchievementsSection-Sm43tnkh.js} +1 -1
  6. package/dashboard/dist/assets/{BadgeDetailModal-CUWTCEru.js → BadgeDetailModal-cSNqMLsK.js} +1 -1
  7. package/dashboard/dist/assets/{Card-C79yXijL.js → Card-wNwNRVSb.js} +1 -1
  8. package/dashboard/dist/assets/{ClawdAnimated-Cw6J-x_d.js → ClawdAnimated-S8sIS2Ca.js} +1 -1
  9. package/dashboard/dist/assets/{CommandPalette-C1RQ58De.js → CommandPalette-DVeiwbPM.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-CycaCGsd.js → CommunityStatsModal-g5P4j3um.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-hPuA2aup.js → ConfirmModal-2P2D5iIp.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-DjunxsRS.js → Controls-CCaotoQP.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-C5H5_EZN.js → DashboardPage-DxPGTJpe.js} +1 -1
  14. package/dashboard/dist/assets/{DevicePage-a1TT-0Bd.js → DevicePage-_nLwNash.js} +1 -1
  15. package/dashboard/dist/assets/{DialogClose-CCRfnIbO.js → DialogClose-D0giFwO3.js} +1 -1
  16. package/dashboard/dist/assets/DialogDescription-8veCvaUb.js +1 -0
  17. package/dashboard/dist/assets/{DialogTitle-2Qa5pApU.js → DialogTitle-JS_04eUC.js} +1 -1
  18. package/dashboard/dist/assets/{FadeIn-C3mpVy83.js → FadeIn-tFzTWDkC.js} +1 -1
  19. package/dashboard/dist/assets/{HeaderGithubStar-Dn_F7ByX.js → HeaderGithubStar-B2EUJUTH.js} +1 -1
  20. package/dashboard/dist/assets/{HoverTooltip-DaywO0Zz.js → HoverTooltip-BJzHksPT.js} +1 -1
  21. package/dashboard/dist/assets/{IpCheckPage-oqGfnWb7.js → IpCheckPage-B8mA0mut.js} +1 -1
  22. package/dashboard/dist/assets/{LandingPage-CX7dYsEX.js → LandingPage-DmJZDKr9.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardAvatar-Bl-OMQ2x.js → LeaderboardAvatar-CDeRQUCg.js} +1 -1
  24. package/dashboard/dist/assets/{LeaderboardPage-yeWcgiVo.js → LeaderboardPage-Dkgm89kY.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardProfileModal-CDmZtQ_m.js → LeaderboardProfileModal-B6_kuR1z.js} +3 -3
  26. package/dashboard/dist/assets/{LeaderboardProfilePage-BqJYxoN1.js → LeaderboardProfilePage-BRs8sJBV.js} +1 -1
  27. package/dashboard/dist/assets/{LimitsPage-D8NFgXbZ.js → LimitsPage-DZ1s5NWV.js} +1 -1
  28. package/dashboard/dist/assets/{LocalOnlyNotice-DjdhDEiq.js → LocalOnlyNotice-Cu_hSnux.js} +1 -1
  29. package/dashboard/dist/assets/{LoginCard-jQXAmSQb.js → LoginCard-yDZbGsHR.js} +1 -1
  30. package/dashboard/dist/assets/{LoginModal-q4G6EN7b.js → LoginModal-DEvzCUfQ.js} +1 -1
  31. package/dashboard/dist/assets/{LoginPage-BklWSXzs.js → LoginPage-DBySF9do.js} +1 -1
  32. package/dashboard/dist/assets/{PetPage-DyWuMUTA.js → PetPage-DnzH9t-r.js} +1 -1
  33. package/dashboard/dist/assets/{PopoverPopup-Dbxed_FL.js → PopoverPopup-DgFsEMax.js} +1 -1
  34. package/dashboard/dist/assets/{ProviderIcon-ChYuSSfj.js → ProviderIcon-BEixe-xP.js} +1 -1
  35. package/dashboard/dist/assets/{ResetPasswordPage-DJww1dxo.js → ResetPasswordPage-excKqsCL.js} +1 -1
  36. package/dashboard/dist/assets/{SegmentedControl-Dr73_QVm.js → SegmentedControl-DDFkH64o.js} +1 -1
  37. package/dashboard/dist/assets/{Select-BMCy_8oY.js → Select-ChEPIuJf.js} +1 -1
  38. package/dashboard/dist/assets/{SelectItemText-BRU7mlBI.js → SelectItemText--b7ISnJm.js} +1 -1
  39. package/dashboard/dist/assets/{ServiceStatusPage-DDao-oTH.js → ServiceStatusPage-DtAY-Qnn.js} +1 -1
  40. package/dashboard/dist/assets/{SessionsPage-BjngkrsY.js → SessionsPage-CKFglvuj.js} +1 -1
  41. package/dashboard/dist/assets/{SettingsPage-Dsftjg5j.js → SettingsPage-s0605qWg.js} +1 -1
  42. package/dashboard/dist/assets/{SkillsPage-igML0DOZ.js → SkillsPage-DTsbCucq.js} +1 -1
  43. package/dashboard/dist/assets/{TokenGalaxy-BBETSYe7.js → TokenGalaxy-C_M7q8S4.js} +1 -1
  44. package/dashboard/dist/assets/{ToolbarRootContext-CzEAxfqj.js → ToolbarRootContext-CXQwpea-.js} +1 -1
  45. package/dashboard/dist/assets/{TrendMonitor-JV6lkw-s.js → TrendMonitor-1Yhlplf0.js} +1 -1
  46. package/dashboard/dist/assets/{WidgetsPage-GpUmv0hI.js → WidgetsPage-DC6pmtld.js} +1 -1
  47. package/dashboard/dist/assets/{WrappedPage-SP2r4wf1.js → WrappedPage-DixoqR13.js} +1 -1
  48. package/dashboard/dist/assets/{__vite-browser-external-AQgdBxou.js → __vite-browser-external-5aLUeBkM.js} +4 -3
  49. package/dashboard/dist/assets/{agent-logos-DxwSc05k.js → agent-logos-DILTbPLg.js} +1 -1
  50. package/dashboard/dist/assets/{arrow-up-right-CDW5zn0Q.js → arrow-up-right-DgIDgjVW.js} +1 -1
  51. package/dashboard/dist/assets/{check-CQfDkdqM.js → check-Bz6l09VH.js} +1 -1
  52. package/dashboard/dist/assets/{chevron-right-DDFSY4w-.js → chevron-right-EvGQXbI3.js} +1 -1
  53. package/dashboard/dist/assets/{copy-hxnyMdBa.js → copy-DbsB0OVy.js} +1 -1
  54. package/dashboard/dist/assets/{download-DOUBKxcf.js → download-R8nUvXYh.js} +1 -1
  55. package/dashboard/dist/assets/{external-link-CVipCZOa.js → external-link-x0oNzGeq.js} +1 -1
  56. package/dashboard/dist/assets/{flame-C-QU6mPf.js → flame-Bo4IdG6k.js} +1 -1
  57. package/dashboard/dist/assets/{format-tokens-DRX15id1.js → format-tokens-BSGC1-Gz.js} +1 -1
  58. package/dashboard/dist/assets/{icons-ajxtn_0O.js → icons-D0Okncmo.js} +1 -1
  59. package/dashboard/dist/assets/{index-OJC-yROZ.js → index-BbOkC6wf.js} +1 -1
  60. package/dashboard/dist/assets/{index-Bq5VZMkS.js → index-Bvvmei-I.js} +1 -1
  61. package/dashboard/dist/assets/{index-DnVXRsqb.js → index-D6F8eNr_.js} +1 -1
  62. package/dashboard/dist/assets/{info-eITfPd17.js → info-DXXKNpLG.js} +1 -1
  63. package/dashboard/dist/assets/{limits-providers-TMq74bRy.js → limits-providers-DYa4fdsE.js} +1 -1
  64. package/dashboard/dist/assets/{link-2-D778MOmL.js → link-2-C9mxEnT6.js} +1 -1
  65. package/dashboard/dist/assets/{loader-circle-uN5rBNIF.js → loader-circle-Y8V8Mq9L.js} +1 -1
  66. package/dashboard/dist/assets/{maximize-2-C4FBbMpN.js → maximize-2-QuP69Xcg.js} +1 -1
  67. package/dashboard/dist/assets/{modifiers.esm-DLeiQDTT.js → modifiers.esm-B3SleRKs.js} +1 -1
  68. package/dashboard/dist/assets/{monitor-smartphone-C-obu2-u.js → monitor-smartphone-B2LslYi7.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-Bjl8tmYu.js → provider-display-DzBwrDZp.js} +2 -2
  70. package/dashboard/dist/assets/{react-Diah7uAT.js → react-BooKG84W.js} +1 -1
  71. package/dashboard/dist/assets/{search-B0bPOquo.js → search-zdYuHmL-.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-6otf2Sdl.js → skills-api-C2IkKfwE.js} +1 -1
  73. package/dashboard/dist/assets/{sortable.esm-DcrJaOfF.js → sortable.esm-BHvl5vG5.js} +1 -1
  74. package/dashboard/dist/assets/{store-DOs4omJM.js → store-BBP4jx0R.js} +1 -1
  75. package/dashboard/dist/assets/{terminal-CpMZjaGk.js → terminal-C0nhFNQ0.js} +1 -1
  76. package/dashboard/dist/assets/{trash-2-BQYnn3zf.js → trash-2-DNVSqbxA.js} +1 -1
  77. package/dashboard/dist/assets/{use-community-stats-CdL4zXXV.js → use-community-stats-BK070d1s.js} +1 -1
  78. package/dashboard/dist/assets/{use-limits-display-prefs-DUh4f8f4.js → use-limits-display-prefs-BXOUxblO.js} +1 -1
  79. package/dashboard/dist/assets/{use-ordered-list-DVs0Ik3O.js → use-ordered-list-0SVHZHaW.js} +1 -1
  80. package/dashboard/dist/assets/use-reduced-motion-abZbtoVS.js +1 -0
  81. package/dashboard/dist/assets/{use-session-efficiency-pref-CGggPI6Y.js → use-session-efficiency-pref-eaUpwuLK.js} +1 -1
  82. package/dashboard/dist/assets/{use-transform-DMj91FEL.js → use-transform-Bhar2U4w.js} +1 -1
  83. package/dashboard/dist/assets/{use-usage-limits-xJOXzuZG.js → use-usage-limits-BGGCMVzi.js} +1 -1
  84. package/dashboard/dist/assets/{useCurrency-BQRBx-KO.js → useCurrency-gIJVmq3w.js} +1 -1
  85. package/dashboard/dist/assets/{useScrollLock-DuTPOBl8.js → useScrollLock-BhFzeRtR.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-Dd95ph6d.js → useTokenFormat-BZ8WT-A6.js} +1 -1
  87. package/dashboard/dist/assets/{zap-DJMdRI6y.js → zap-Dvc_Jt1t.js} +1 -1
  88. package/dashboard/dist/brand-logos/omp.svg +33 -0
  89. package/dashboard/dist/brand-logos/pi.svg +16 -0
  90. package/dashboard/dist/index.html +1 -1
  91. package/dashboard/dist/ip-check.html +1 -1
  92. package/dashboard/dist/leaderboard.html +1 -1
  93. package/dashboard/dist/share.html +1 -1
  94. package/package.json +1 -1
  95. package/src/commands/init.js +49 -8
  96. package/src/commands/status.js +12 -5
  97. package/src/commands/uninstall.js +36 -1
  98. package/src/lib/omp-hook.js +589 -0
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/dashboard/dist/assets/DialogDescription-C99d5K4r.js +0 -1
  101. package/dashboard/dist/assets/use-reduced-motion--_Yc7AWk.js +0 -1
@@ -84,6 +84,7 @@ const wsl = require("../lib/wsl-probe");
84
84
  const { getWslMode, isInvalidWslMode, shouldProbeWsl, discoverWslHome } = wsl;
85
85
  const { resolveInstallPaths } = require("../lib/install-resolver");
86
86
  const { probeGrokHookState, resolveGrokHome } = require("../lib/grok-hook");
87
+ const { probeOmpHookState } = require("../lib/omp-hook");
87
88
 
88
89
  function formatResolvedPaths(paths, filename) {
89
90
  const active = [];
@@ -359,10 +360,11 @@ async function cmdStatus(argv = []) {
359
360
  ? fssync.existsSync(path.join(workbuddyHome, "workbuddy.db"))
360
361
  : false;
361
362
 
362
- // oh-my-pi — passive scan only (no hooks).
363
+ // oh-my-pi — passive scan + optional notify extension.
363
364
  const ompAgentDir = resolveOmpAgentDir(process.env);
364
365
  const ompInstalled = Boolean(ompAgentDir) && fssync.existsSync(path.join(ompAgentDir, "sessions"));
365
366
  const ompFiles = ompInstalled ? resolveOmpSessionFiles(process.env) : [];
367
+ const ompHookState = await probeOmpHookState({ home, trackerDir, env: process.env });
366
368
 
367
369
  // pi (@mariozechner/pi-coding-agent) — passive scan only (no hooks).
368
370
  // Skip when its agent dir collides with omp's; sync would dedupe anyway.
@@ -771,8 +773,13 @@ async function cmdStatus(argv = []) {
771
773
  workbuddy: workbuddyInstalled
772
774
  ? { installed: true, files: workbuddyFiles.length }
773
775
  : { installed: false },
774
- omp: ompInstalled
775
- ? { installed: true, files: ompFiles.length }
776
+ omp: ompInstalled || ompHookState.ompPresent
777
+ ? {
778
+ installed: true,
779
+ files: ompFiles.length,
780
+ notify_extension: Boolean(ompHookState.configured),
781
+ notify_extension_path: ompHookState.extensionPath || null,
782
+ }
776
783
  : { installed: false },
777
784
  pi: piInstalled
778
785
  ? { installed: true, files: piFiles.length }
@@ -895,8 +902,8 @@ async function cmdStatus(argv = []) {
895
902
  workbuddyInstalled
896
903
  ? `- WorkBuddy hooks: ${workbuddyHookConfigured ? "set" : "unset"} (${workbuddyFiles.length} session jsonl file${workbuddyFiles.length !== 1 ? "s" : ""} found, SQLite DB ${workbuddyDbExists ? "found" : "not found"})`
897
904
  : null,
898
- ompInstalled
899
- ? `- oh-my-pi: passive reader (${ompFiles.length} session jsonl file${ompFiles.length !== 1 ? "s" : ""} found)`
905
+ ompInstalled || ompHookState.ompPresent
906
+ ? `- oh-my-pi: passive reader (${ompFiles.length} session jsonl file${ompFiles.length !== 1 ? "s" : ""} found${ompHookState.configured ? ", notify extension: yes" : ", notify extension: no"})`
900
907
  : null,
901
908
  piInstalled
902
909
  ? `- pi: passive reader (${piFiles.length} session jsonl file${piFiles.length !== 1 ? "s" : ""} found)`
@@ -24,6 +24,7 @@ const { resolveOpencodeConfigDir, removeOpencodePlugin } = require("../lib/openc
24
24
  const { removeOpenclawHookConfig } = require("../lib/openclaw-hook");
25
25
  const { removeOpenclawSessionPluginConfig } = require("../lib/openclaw-session-plugin");
26
26
  const { removeGrokHook } = require("../lib/grok-hook");
27
+ const { removeOmpHook } = require("../lib/omp-hook");
27
28
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
28
29
 
29
30
  async function cmdUninstall(argv) {
@@ -104,6 +105,7 @@ async function cmdUninstall(argv) {
104
105
  });
105
106
  const openclawHookRemove = await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
106
107
  const grokHookRemove = await removeGrokHook({ home, trackerDir, env: process.env });
108
+ const ompHookRemove = await removeOmpHook({ home, trackerDir, env: process.env });
107
109
 
108
110
  // Remove installed notify handler.
109
111
  await fs.unlink(notifyPath).catch(() => {});
@@ -195,6 +197,7 @@ async function cmdUninstall(argv) {
195
197
  grokHookRemove?.removed
196
198
  ? `- Grok Build hook removed: ${grokHookRemove.hookPath}`
197
199
  : "- Grok Build hook: no change",
200
+ formatOmpHookRemoveLine(ompHookRemove),
198
201
  opts.purge ? `- Purged: ${path.join(home, ".tokentracker")}` : "- Purge: skipped (use --purge)",
199
202
  ...(machineIdSeedKept
200
203
  ? [`- Kept: ${machineIdSeedPath} (cloud device identity — a reinstall reuses the same device; delete it to fully reset)`]
@@ -204,6 +207,38 @@ async function cmdUninstall(argv) {
204
207
  );
205
208
  }
206
209
 
210
+ function formatOmpHookRemoveLine(ompHookRemove) {
211
+ if (ompHookRemove?.removed) {
212
+ return `- oh-my-pi notify extension removed: ${ompHookRemove.extensionPath}`;
213
+ }
214
+ const reason = ompHookRemove?.skippedReason;
215
+ const residualPath = ompHookRemove?.stagedPath || ompHookRemove?.extensionPath;
216
+ const residual = residualPath
217
+ ? ` (left in place: ${residualPath})`
218
+ : "";
219
+ if (reason === "unmanaged") {
220
+ return `- oh-my-pi notify extension: skipped (unmanaged file)${residual}`;
221
+ }
222
+ if (reason === "unlink-failed") {
223
+ const detail = ompHookRemove?.error ? `: ${ompHookRemove.error}` : "";
224
+ return `- oh-my-pi notify extension: failed to remove${detail}${residual}`;
225
+ }
226
+ if (reason === "extension-read-failed") {
227
+ const detail = ompHookRemove?.error ? `: ${ompHookRemove.error}` : "";
228
+ return `- oh-my-pi notify extension: failed to read${detail}${residual}`;
229
+ }
230
+ if (reason === "identity-changed") {
231
+ return `- oh-my-pi notify extension: skipped (file changed during uninstall)${residual}`;
232
+ }
233
+ if (reason === "omp-agent-dir-unresolved") {
234
+ return "- oh-my-pi notify extension: skipped (omp agent dir unresolved)";
235
+ }
236
+ if (reason === "missing") {
237
+ return "- oh-my-pi notify extension: no change";
238
+ }
239
+ return "- oh-my-pi notify extension: no change";
240
+ }
241
+
207
242
  function parseArgs(argv) {
208
243
  const out = { purge: false };
209
244
  for (let i = 0; i < argv.length; i++) {
@@ -214,7 +249,7 @@ function parseArgs(argv) {
214
249
  return out;
215
250
  }
216
251
 
217
- module.exports = { cmdUninstall };
252
+ module.exports = { cmdUninstall, formatOmpHookRemoveLine };
218
253
 
219
254
  async function isFile(p) {
220
255
  try {
@@ -0,0 +1,589 @@
1
+ "use strict";
2
+
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const fs = require("node:fs/promises");
6
+ const fssync = require("node:fs");
7
+
8
+ // Reuse the passive scanner's path resolvers so init/status/uninstall target
9
+ // the same OMP installation that sync scans (HOME overrides, PI_CONFIG_DIR,
10
+ // PI_CODING_AGENT_DIR ownership, ~ expansion, Windows/WSL paths).
11
+ const {
12
+ resolveOmpHome,
13
+ resolveOmpAgentDir,
14
+ } = require("./rollout");
15
+
16
+ const EXTENSION_FILENAME = "tokentracker-notify.ts";
17
+ const MARKER = "// @tokentracker-managed-omp-extension";
18
+
19
+ /**
20
+ * Test-only injection points for deterministic race repros.
21
+ * Production code never sets these; tests may assign async functions:
22
+ * - beforeExclusiveCreate(extensionPath)
23
+ * - beforeManagedWrite(extensionPath, identity)
24
+ * - beforeUnlink(extensionPath, identity)
25
+ * - afterStagingRename(extensionPath, stagingPath)
26
+ */
27
+ const _testHooks = {
28
+ beforeExclusiveCreate: null,
29
+ beforeManagedWrite: null,
30
+ beforeUnlink: null,
31
+ afterStagingRename: null,
32
+ };
33
+
34
+ function resolveOmpExtensionsDir(env = process.env) {
35
+ const agentDir = resolveOmpAgentDir(env);
36
+ if (!agentDir) return null;
37
+ return path.join(agentDir, "extensions");
38
+ }
39
+
40
+ function resolveTrackerBinDir(trackerDir) {
41
+ if (!trackerDir) throw new Error("trackerDir is required");
42
+ return path.basename(trackerDir) === "tracker"
43
+ ? path.join(path.dirname(trackerDir), "bin")
44
+ : path.join(trackerDir, "bin");
45
+ }
46
+
47
+ /**
48
+ * Extension source written into ~/.omp/agent/extensions/tokentracker-notify.ts.
49
+ * Uses require()-style node builtins so omp's extension rewriter can parse it.
50
+ * Spawns notify.cjs --source=omp (already in AUTO_SYNC_SOURCES) after turns.
51
+ */
52
+ function buildOmpNotifyExtensionSource({ notifyPath }) {
53
+ const notifyLiteral = JSON.stringify(notifyPath);
54
+ return `${MARKER}
55
+ // TokenTracker notify bridge for oh-my-pi (omp).
56
+ // Passive session scanning still works without this file; the extension only
57
+ // makes local dashboard refresh near-real-time after each agent turn.
58
+ // Managed by TokenTracker init/uninstall — do not hand-edit the marker line.
59
+
60
+ const { spawn } = require("node:child_process");
61
+ const fs = require("node:fs");
62
+ const os = require("node:os");
63
+ const path = require("node:path");
64
+
65
+ const SOURCE = "omp";
66
+ const MIN_INTERVAL_MS = 12_000;
67
+ const MANAGED_NOTIFY = ${notifyLiteral};
68
+
69
+ let lastSpawnAt = 0;
70
+ let pendingTimer = null;
71
+ let warnedMissing = false;
72
+
73
+ function resolveNotifyPath() {
74
+ const candidates = [];
75
+ if (process.env.TOKENTRACKER_NOTIFY) candidates.push(process.env.TOKENTRACKER_NOTIFY);
76
+ if (MANAGED_NOTIFY) candidates.push(MANAGED_NOTIFY);
77
+ candidates.push(path.join(os.homedir(), ".tokentracker", "bin", "notify.cjs"));
78
+ for (const candidate of candidates) {
79
+ try {
80
+ if (candidate && fs.existsSync(candidate)) return candidate;
81
+ } catch (_) {}
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function spawnNotify() {
87
+ const notifyPath = resolveNotifyPath();
88
+ if (!notifyPath) {
89
+ if (!warnedMissing) {
90
+ warnedMissing = true;
91
+ console.warn(
92
+ "[tokentracker-notify] notify.cjs not found; expected ~/.tokentracker/bin/notify.cjs",
93
+ );
94
+ }
95
+ return;
96
+ }
97
+
98
+ const now = Date.now();
99
+ if (now - lastSpawnAt < MIN_INTERVAL_MS) {
100
+ if (pendingTimer == null) {
101
+ const wait = Math.max(MIN_INTERVAL_MS - (now - lastSpawnAt), 50);
102
+ pendingTimer = setTimeout(() => {
103
+ pendingTimer = null;
104
+ spawnNotify();
105
+ }, wait);
106
+ if (typeof pendingTimer.unref === "function") pendingTimer.unref();
107
+ }
108
+ return;
109
+ }
110
+ lastSpawnAt = now;
111
+ if (pendingTimer != null) {
112
+ clearTimeout(pendingTimer);
113
+ pendingTimer = null;
114
+ }
115
+
116
+ try {
117
+ const child = spawn(process.execPath, [notifyPath, "--source=" + SOURCE], {
118
+ detached: true,
119
+ stdio: "ignore",
120
+ env: process.env,
121
+ });
122
+ child.unref();
123
+ } catch (err) {
124
+ if (!warnedMissing) {
125
+ warnedMissing = true;
126
+ console.warn(
127
+ "[tokentracker-notify] failed to spawn notify:",
128
+ err && err.message ? err.message : String(err),
129
+ );
130
+ }
131
+ }
132
+ }
133
+
134
+ export default function (pi) {
135
+ pi.on("turn_end", () => {
136
+ spawnNotify();
137
+ });
138
+
139
+ pi.on("agent_end", () => {
140
+ spawnNotify();
141
+ });
142
+
143
+ pi.on("session_shutdown", () => {
144
+ lastSpawnAt = 0;
145
+ if (pendingTimer != null) {
146
+ clearTimeout(pendingTimer);
147
+ pendingTimer = null;
148
+ }
149
+ spawnNotify();
150
+ });
151
+ }
152
+ `;
153
+ }
154
+
155
+ function isManagedOmpExtension(content) {
156
+ return typeof content === "string" && content.includes(MARKER);
157
+ }
158
+
159
+ function fileIdentity(stat) {
160
+ if (!stat) return null;
161
+ return { dev: stat.dev, ino: stat.ino };
162
+ }
163
+
164
+ function sameFileIdentity(a, b) {
165
+ return Boolean(a && b && a.dev === b.dev && a.ino === b.ino);
166
+ }
167
+
168
+ async function pathIdentity(filePath) {
169
+ try {
170
+ return fileIdentity(await fs.stat(filePath));
171
+ } catch (err) {
172
+ if (err && err.code === "ENOENT") return null;
173
+ throw err;
174
+ }
175
+ }
176
+
177
+ async function probeOmpHookState({ home = os.homedir(), trackerDir, env = process.env } = {}) {
178
+ const agentDir = resolveOmpAgentDir(env);
179
+ const extensionsDir = resolveOmpExtensionsDir(env);
180
+ const extensionPath = extensionsDir
181
+ ? path.join(extensionsDir, EXTENSION_FILENAME)
182
+ : null;
183
+ const sessionsDir = agentDir ? path.join(agentDir, "sessions") : null;
184
+ const ompPresent = Boolean(
185
+ (agentDir && fssync.existsSync(agentDir)) ||
186
+ (sessionsDir && fssync.existsSync(sessionsDir)),
187
+ );
188
+
189
+ let configured = false;
190
+ let managed = false;
191
+ let exists = false;
192
+ if (extensionPath && fssync.existsSync(extensionPath)) {
193
+ exists = true;
194
+ try {
195
+ const content = await fs.readFile(extensionPath, "utf8");
196
+ managed = isManagedOmpExtension(content);
197
+ // Treat any tokentracker-notify extension as configured for status UX.
198
+ // Ownership decisions (overwrite/remove) still require the exact MARKER.
199
+ configured = managed || /notify\.cjs|--source=omp|tokentracker/i.test(content);
200
+ } catch {
201
+ configured = false;
202
+ }
203
+ }
204
+
205
+ return {
206
+ ompPresent,
207
+ agentDir,
208
+ extensionsDir,
209
+ extensionPath,
210
+ exists,
211
+ configured,
212
+ managed,
213
+ sessionsDir,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Write managed source through an already-opened handle, then confirm the
219
+ * directory entry still refers to that same inode (so a path replacement
220
+ * during the write cannot be reported as success against a user file).
221
+ */
222
+ async function writeManagedThroughHandle(handle, extensionPath, source, openedIdentity) {
223
+ if (typeof _testHooks.beforeManagedWrite === "function") {
224
+ await _testHooks.beforeManagedWrite(extensionPath, openedIdentity);
225
+ }
226
+
227
+ let pathIdBefore;
228
+ try {
229
+ pathIdBefore = await pathIdentity(extensionPath);
230
+ } catch (err) {
231
+ return {
232
+ written: false,
233
+ skippedReason: "identity-check-failed",
234
+ error: String(err?.message || err),
235
+ };
236
+ }
237
+ if (!sameFileIdentity(pathIdBefore, openedIdentity)) {
238
+ return { written: false, skippedReason: "identity-changed" };
239
+ }
240
+
241
+ try {
242
+ await handle.truncate(0);
243
+ const sourceBytes = Buffer.from(source, "utf8");
244
+ let offset = 0;
245
+ while (offset < sourceBytes.length) {
246
+ const { bytesWritten } = await handle.write(
247
+ sourceBytes,
248
+ offset,
249
+ sourceBytes.length - offset,
250
+ offset,
251
+ );
252
+ if (bytesWritten <= 0) {
253
+ throw new Error("Failed to write managed oh-my-pi extension");
254
+ }
255
+ offset += bytesWritten;
256
+ }
257
+ } catch (err) {
258
+ return {
259
+ written: false,
260
+ skippedReason: "extension-write-failed",
261
+ error: String(err?.message || err),
262
+ };
263
+ }
264
+
265
+ let pathIdAfter;
266
+ try {
267
+ pathIdAfter = await pathIdentity(extensionPath);
268
+ } catch (err) {
269
+ return {
270
+ written: false,
271
+ skippedReason: "identity-check-failed",
272
+ error: String(err?.message || err),
273
+ };
274
+ }
275
+ if (!sameFileIdentity(pathIdAfter, openedIdentity)) {
276
+ // Path now points elsewhere (or is gone). The handle write hit the
277
+ // original managed inode only; do not claim ownership of the path.
278
+ return { written: false, skippedReason: "identity-changed" };
279
+ }
280
+ return { written: true };
281
+ }
282
+
283
+ async function upsertOmpHook({ home = os.homedir(), trackerDir, env = process.env } = {}) {
284
+ if (!trackerDir) throw new Error("trackerDir is required");
285
+ const binDir = resolveTrackerBinDir(trackerDir);
286
+ const notifyPath = path.join(binDir, "notify.cjs");
287
+ const extensionsDir = resolveOmpExtensionsDir(env);
288
+ if (!extensionsDir) {
289
+ return {
290
+ written: false,
291
+ skippedReason: "omp-agent-dir-unresolved",
292
+ extensionPath: null,
293
+ notifyPath,
294
+ };
295
+ }
296
+ const extensionPath = path.join(extensionsDir, EXTENSION_FILENAME);
297
+ const source = buildOmpNotifyExtensionSource({ notifyPath });
298
+
299
+ await fs.mkdir(extensionsDir, { recursive: true });
300
+
301
+ if (typeof _testHooks.beforeExclusiveCreate === "function") {
302
+ await _testHooks.beforeExclusiveCreate(extensionPath);
303
+ }
304
+
305
+ // Create path: exclusive open so a concurrent user create cannot be clobbered.
306
+ try {
307
+ const createHandle = await fs.open(extensionPath, "wx");
308
+ try {
309
+ await createHandle.writeFile(source, "utf8");
310
+ } finally {
311
+ await createHandle.close();
312
+ }
313
+ const state = await probeOmpHookState({ home, trackerDir, env });
314
+ return {
315
+ written: true,
316
+ extensionPath,
317
+ notifyPath,
318
+ state,
319
+ };
320
+ } catch (err) {
321
+ if (!err || err.code !== "EEXIST") {
322
+ return {
323
+ written: false,
324
+ skippedReason: "extension-write-failed",
325
+ error: String(err?.message || err),
326
+ extensionPath,
327
+ notifyPath,
328
+ };
329
+ }
330
+ // Fall through: path exists — only rewrite if still our managed inode.
331
+ }
332
+
333
+ // Update path: open by path, verify marker on the opened inode, mutate only
334
+ // that inode, and refuse if the directory entry changes identity mid-flight.
335
+ let handle;
336
+ try {
337
+ handle = await fs.open(extensionPath, "r+");
338
+ } catch (err) {
339
+ if (err && err.code === "ENOENT") {
340
+ // Lost the race to a delete; retry exclusive create once without the
341
+ // create-hook (hook already ran for the first attempt).
342
+ try {
343
+ const createHandle = await fs.open(extensionPath, "wx");
344
+ try {
345
+ await createHandle.writeFile(source, "utf8");
346
+ } finally {
347
+ await createHandle.close();
348
+ }
349
+ const state = await probeOmpHookState({ home, trackerDir, env });
350
+ return { written: true, extensionPath, notifyPath, state };
351
+ } catch (retryErr) {
352
+ if (retryErr && retryErr.code === "EEXIST") {
353
+ // Concurrent create won; re-enter ownership check below.
354
+ try {
355
+ handle = await fs.open(extensionPath, "r+");
356
+ } catch (openErr) {
357
+ return {
358
+ written: false,
359
+ skippedReason: "extension-read-failed",
360
+ error: String(openErr?.message || openErr),
361
+ extensionPath,
362
+ notifyPath,
363
+ };
364
+ }
365
+ } else {
366
+ return {
367
+ written: false,
368
+ skippedReason: "extension-write-failed",
369
+ error: String(retryErr?.message || retryErr),
370
+ extensionPath,
371
+ notifyPath,
372
+ };
373
+ }
374
+ }
375
+ } else {
376
+ return {
377
+ written: false,
378
+ skippedReason: "extension-read-failed",
379
+ error: String(err?.message || err),
380
+ extensionPath,
381
+ notifyPath,
382
+ };
383
+ }
384
+ }
385
+
386
+ try {
387
+ const existing = await handle.readFile("utf8");
388
+ if (!isManagedOmpExtension(existing)) {
389
+ return {
390
+ written: false,
391
+ skippedReason: "unmanaged-extension-present",
392
+ extensionPath,
393
+ notifyPath,
394
+ };
395
+ }
396
+
397
+ const openedIdentity = fileIdentity(await handle.stat());
398
+ const writeResult = await writeManagedThroughHandle(
399
+ handle,
400
+ extensionPath,
401
+ source,
402
+ openedIdentity,
403
+ );
404
+ if (!writeResult.written) {
405
+ return {
406
+ written: false,
407
+ skippedReason: writeResult.skippedReason,
408
+ ...(writeResult.error ? { error: writeResult.error } : {}),
409
+ extensionPath,
410
+ notifyPath,
411
+ };
412
+ }
413
+
414
+ const state = await probeOmpHookState({ home, trackerDir, env });
415
+ return {
416
+ written: true,
417
+ extensionPath,
418
+ notifyPath,
419
+ state,
420
+ };
421
+ } finally {
422
+ await handle.close().catch(() => {});
423
+ }
424
+ }
425
+
426
+ // Restore without replacing a concurrent destination; keep staging on failure.
427
+ async function restoreStagedExtension(stagingPath, extensionPath) {
428
+ try {
429
+ await fs.copyFile(stagingPath, extensionPath, fssync.constants.COPYFILE_EXCL);
430
+ } catch (error) {
431
+ return { restored: false, error };
432
+ }
433
+
434
+ try {
435
+ await fs.unlink(stagingPath);
436
+ } catch (error) {
437
+ return { restored: true, staleStaging: true, error };
438
+ }
439
+
440
+ return { restored: true };
441
+ }
442
+
443
+ async function removeOmpHook({ home = os.homedir(), trackerDir, env = process.env } = {}) {
444
+ const extensionsDir = resolveOmpExtensionsDir(env);
445
+ if (!extensionsDir) {
446
+ return { removed: false, skippedReason: "omp-agent-dir-unresolved", extensionPath: null };
447
+ }
448
+ const extensionPath = path.join(extensionsDir, EXTENSION_FILENAME);
449
+
450
+ // Peek first so the common unmanaged/missing cases do not thrash the path.
451
+ try {
452
+ const peek = await fs.readFile(extensionPath, "utf8");
453
+ if (!isManagedOmpExtension(peek)) {
454
+ return { removed: false, skippedReason: "unmanaged", extensionPath };
455
+ }
456
+ } catch (err) {
457
+ if (err && err.code === "ENOENT") {
458
+ return { removed: false, skippedReason: "missing", extensionPath };
459
+ }
460
+ return {
461
+ removed: false,
462
+ skippedReason: "extension-read-failed",
463
+ error: String(err?.message || err),
464
+ extensionPath,
465
+ };
466
+ }
467
+
468
+ if (typeof _testHooks.beforeUnlink === "function") {
469
+ await _testHooks.beforeUnlink(extensionPath, null);
470
+ }
471
+
472
+ // Atomically claim the directory entry via rename. The staged inode remains
473
+ // private until restoreStagedExtension copies it back without replacing a
474
+ // concurrent replacement at the original path.
475
+ const stagingPath = path.join(
476
+ extensionsDir,
477
+ `.${EXTENSION_FILENAME}.tokentracker-removing-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
478
+ );
479
+
480
+ try {
481
+ await fs.rename(extensionPath, stagingPath);
482
+ } catch (err) {
483
+ if (err && err.code === "ENOENT") {
484
+ return { removed: false, skippedReason: "missing", extensionPath };
485
+ }
486
+ return {
487
+ removed: false,
488
+ skippedReason: "unlink-failed",
489
+ error: String(err?.message || err),
490
+ extensionPath,
491
+ };
492
+ }
493
+
494
+ if (typeof _testHooks.afterStagingRename === "function") {
495
+ await _testHooks.afterStagingRename(extensionPath, stagingPath);
496
+ }
497
+
498
+ let stagedContent;
499
+ try {
500
+ stagedContent = await fs.readFile(stagingPath, "utf8");
501
+ } catch (err) {
502
+ // Best-effort restore if we cannot inspect what we claimed.
503
+ const restoreResult = await restoreStagedExtension(stagingPath, extensionPath);
504
+ return {
505
+ removed: false,
506
+ skippedReason: "extension-read-failed",
507
+ error: String(err?.message || err),
508
+ extensionPath,
509
+ ...(restoreResult.staleStaging
510
+ ? {
511
+ staleStaging: true,
512
+ stagedPath: stagingPath,
513
+ stagingError: String(restoreResult.error?.message || restoreResult.error),
514
+ }
515
+ : restoreResult.restored
516
+ ? {}
517
+ : { stagedPath: stagingPath }),
518
+ };
519
+ }
520
+
521
+ if (!isManagedOmpExtension(stagedContent)) {
522
+ // Restored user/replacement file under the original path.
523
+ const restoreResult = await restoreStagedExtension(stagingPath, extensionPath);
524
+ if (!restoreResult.restored) {
525
+ // If the original path was recreated, leave the staged file for the user.
526
+ return {
527
+ removed: false,
528
+ skippedReason: "identity-changed",
529
+ error: String(restoreResult.error?.message || restoreResult.error),
530
+ extensionPath,
531
+ stagedPath: stagingPath,
532
+ };
533
+ }
534
+ return {
535
+ removed: false,
536
+ skippedReason: "identity-changed",
537
+ extensionPath,
538
+ ...(restoreResult.staleStaging
539
+ ? {
540
+ staleStaging: true,
541
+ stagedPath: stagingPath,
542
+ stagingError: String(restoreResult.error?.message || restoreResult.error),
543
+ }
544
+ : {}),
545
+ };
546
+ }
547
+
548
+ try {
549
+ await fs.unlink(stagingPath);
550
+ } catch (err) {
551
+ if (err?.code === "ENOENT") {
552
+ return { removed: true, extensionPath };
553
+ }
554
+ // Restore the managed file so uninstall does not leave a silent orphan
555
+ // under a temp name when the final delete is blocked.
556
+ const restoreResult = await restoreStagedExtension(stagingPath, extensionPath);
557
+ return {
558
+ removed: false,
559
+ skippedReason: "unlink-failed",
560
+ error: String(err?.message || err),
561
+ extensionPath,
562
+ ...(restoreResult.staleStaging
563
+ ? {
564
+ staleStaging: true,
565
+ stagedPath: stagingPath,
566
+ stagingError: String(restoreResult.error?.message || restoreResult.error),
567
+ }
568
+ : restoreResult.restored
569
+ ? {}
570
+ : { stagedPath: stagingPath }),
571
+ };
572
+ }
573
+
574
+ return { removed: true, extensionPath };
575
+ }
576
+
577
+ module.exports = {
578
+ EXTENSION_FILENAME,
579
+ MARKER,
580
+ resolveOmpHome,
581
+ resolveOmpAgentDir,
582
+ resolveOmpExtensionsDir,
583
+ buildOmpNotifyExtensionSource,
584
+ isManagedOmpExtension,
585
+ probeOmpHookState,
586
+ upsertOmpHook,
587
+ removeOmpHook,
588
+ _testHooks,
589
+ };