tokentracker-cli 0.87.7 → 0.87.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dashboard/dist/assets/{AchievementBadge-4SSFyzNE.js → AchievementBadge-D3_hERkQ.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-4ASCXja2.js → AchievementsPage-DZGjT92g.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CyaMYMol.js → AchievementsSection-CEzq3U3A.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-CUWTCEru.js → BadgeDetailModal-D3_B8E--.js} +1 -1
- package/dashboard/dist/assets/{Card-C79yXijL.js → Card-CMXM3-lp.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-Cw6J-x_d.js → ClawdAnimated-lQxUKRzw.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-C1RQ58De.js → CommandPalette-BCGdAeFB.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-CycaCGsd.js → CommunityStatsModal-BwE5h2y5.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-hPuA2aup.js → ConfirmModal-8uhS2ccj.js} +1 -1
- package/dashboard/dist/assets/{Controls-DjunxsRS.js → Controls-CBEQTl2X.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-C5H5_EZN.js → DashboardPage-UE_3tGVy.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-a1TT-0Bd.js → DevicePage-B06ybHJI.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-CCRfnIbO.js → DialogClose-L_t-nrYm.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-B7FFtR7A.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-2Qa5pApU.js → DialogTitle-Bsft4VvL.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-C3mpVy83.js → FadeIn-BNDdLqTu.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Dn_F7ByX.js → HeaderGithubStar-CvNaKDbl.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DaywO0Zz.js → HoverTooltip-D5j5IpNx.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-oqGfnWb7.js → IpCheckPage-Csf8gMJW.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-CX7dYsEX.js → LandingPage-BVSSwM0D.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-Bl-OMQ2x.js → LeaderboardAvatar-DXxSHqmP.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-yeWcgiVo.js → LeaderboardPage-DXj21BBR.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-CDmZtQ_m.js → LeaderboardProfileModal-DbEzZGg4.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BqJYxoN1.js → LeaderboardProfilePage-DoIm3jB_.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-D8NFgXbZ.js → LimitsPage-D_tpQrUM.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-DjdhDEiq.js → LocalOnlyNotice-DBi5iuPr.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-jQXAmSQb.js → LoginCard-qDIJgUX1.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-q4G6EN7b.js → LoginModal-D4b5p4u1.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-BklWSXzs.js → LoginPage-CsSgItFA.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DyWuMUTA.js → PetPage-B-wElg5q.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-Dbxed_FL.js → PopoverPopup-9C_VarUQ.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-ChYuSSfj.js → ProviderIcon-CciEdEeR.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DJww1dxo.js → ResetPasswordPage-lsI8qlzE.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-Dr73_QVm.js → SegmentedControl-BGonMV45.js} +1 -1
- package/dashboard/dist/assets/{Select-BMCy_8oY.js → Select-DhRrGrDT.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-BRU7mlBI.js → SelectItemText-ClMYgZsa.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-DDao-oTH.js → ServiceStatusPage-DuzttgL7.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-BjngkrsY.js → SessionsPage-gZbxx0f_.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Dsftjg5j.js → SettingsPage-NhxTrVNW.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-igML0DOZ.js → SkillsPage-BeXojJqQ.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-BBETSYe7.js → TokenGalaxy-DM49zKzo.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-CzEAxfqj.js → ToolbarRootContext-BfvoVNMW.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-JV6lkw-s.js → TrendMonitor-Bu-dXcrV.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-GpUmv0hI.js → WidgetsPage-D2Wo7seE.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-SP2r4wf1.js → WrappedPage-BXAwic4D.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-AQgdBxou.js → __vite-browser-external-BDEQk--l.js} +4 -3
- package/dashboard/dist/assets/{agent-logos-DxwSc05k.js → agent-logos-DMEEHjG7.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CDW5zn0Q.js → arrow-up-right-gNviKtY8.js} +1 -1
- package/dashboard/dist/assets/{check-CQfDkdqM.js → check-Cvg5pmYP.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-DDFSY4w-.js → chevron-right-DxdaNTo8.js} +1 -1
- package/dashboard/dist/assets/{copy-hxnyMdBa.js → copy-BNIJL7bf.js} +1 -1
- package/dashboard/dist/assets/{download-DOUBKxcf.js → download-C9McJlPa.js} +1 -1
- package/dashboard/dist/assets/{external-link-CVipCZOa.js → external-link-C_IU6zDK.js} +1 -1
- package/dashboard/dist/assets/{flame-C-QU6mPf.js → flame-CII7X8Je.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DRX15id1.js → format-tokens-DuQbGpPt.js} +1 -1
- package/dashboard/dist/assets/{icons-ajxtn_0O.js → icons-B1YTqnxG.js} +1 -1
- package/dashboard/dist/assets/{index-DnVXRsqb.js → index-BRMxraNK.js} +1 -1
- package/dashboard/dist/assets/{index-OJC-yROZ.js → index-Dc9xsIu2.js} +1 -1
- package/dashboard/dist/assets/{index-Bq5VZMkS.js → index-QtcYir6O.js} +1 -1
- package/dashboard/dist/assets/{info-eITfPd17.js → info-CIgVqJoj.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-TMq74bRy.js → limits-providers-B31NsGar.js} +1 -1
- package/dashboard/dist/assets/{link-2-D778MOmL.js → link-2-DTPXzHuB.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-uN5rBNIF.js → loader-circle-CgQSyCz8.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-C4FBbMpN.js → maximize-2-BL5J4aHZ.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DLeiQDTT.js → modifiers.esm-s_rzcEf2.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-C-obu2-u.js → monitor-smartphone-C9ei_xde.js} +1 -1
- package/dashboard/dist/assets/{provider-display-Bjl8tmYu.js → provider-display-ZgvoJi05.js} +2 -2
- package/dashboard/dist/assets/{react-Diah7uAT.js → react-DhfPVGOm.js} +1 -1
- package/dashboard/dist/assets/{search-B0bPOquo.js → search-Ba_MWO7q.js} +1 -1
- package/dashboard/dist/assets/{skills-api-6otf2Sdl.js → skills-api-BkmJmyg1.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-DcrJaOfF.js → sortable.esm-Bmd5t12E.js} +1 -1
- package/dashboard/dist/assets/{store-DOs4omJM.js → store-BxRQs9yc.js} +1 -1
- package/dashboard/dist/assets/{terminal-CpMZjaGk.js → terminal-BzUwAs4g.js} +1 -1
- package/dashboard/dist/assets/{trash-2-BQYnn3zf.js → trash-2-CCprY7cu.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-CdL4zXXV.js → use-community-stats-TCdPJ_ir.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-DUh4f8f4.js → use-limits-display-prefs-CNF9luO6.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-DVs0Ik3O.js → use-ordered-list-BlTuSYJy.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-CfiR91Eg.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-CGggPI6Y.js → use-session-efficiency-pref-iHS0O_Fo.js} +1 -1
- package/dashboard/dist/assets/{use-transform-DMj91FEL.js → use-transform-CcVfL3mi.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-xJOXzuZG.js → use-usage-limits-Xdy-Y_1Z.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BQRBx-KO.js → useCurrency-IXccXbmx.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-DuTPOBl8.js → useScrollLock-BEVewfZm.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-Dd95ph6d.js → useTokenFormat-Cihv3Ghl.js} +1 -1
- package/dashboard/dist/assets/{zap-DJMdRI6y.js → zap-zG2Fbb5_.js} +1 -1
- package/dashboard/dist/brand-logos/omp.svg +33 -0
- package/dashboard/dist/brand-logos/pi.svg +16 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/commands/init.js +49 -8
- package/src/commands/status.js +12 -5
- package/src/commands/sync.js +245 -1
- package/src/commands/uninstall.js +36 -1
- package/src/lib/install-resolver.js +22 -6
- package/src/lib/omp-hook.js +589 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +272 -32
- package/dashboard/dist/assets/DialogDescription-C99d5K4r.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion--_Yc7AWk.js +0 -1
|
@@ -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
|
+
};
|