sidekick-shared 0.27.6 → 0.28.0
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/CHANGELOG.md +28 -0
- package/README.md +58 -18
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -2
- package/dist/claudePaths.d.ts +11 -0
- package/dist/claudePaths.js +61 -0
- package/dist/claudeProfiles.d.ts +2 -9
- package/dist/claudeProfiles.js +10 -25
- package/dist/codexPaths.d.ts +22 -0
- package/dist/codexPaths.js +67 -0
- package/dist/codexProfiles.d.ts +1 -4
- package/dist/codexProfiles.js +5 -43
- package/dist/doctor.js +2 -2
- package/dist/esm/accountChangeSignal.js +15 -0
- package/dist/esm/accountChanges.js +152 -0
- package/dist/esm/accountHealth.js +288 -0
- package/dist/esm/accountLaunch.js +145 -0
- package/dist/esm/accountLease.js +60 -0
- package/dist/esm/accountManager.js +606 -0
- package/dist/esm/accountRegistry.js +214 -0
- package/dist/esm/accountStatus.js +51 -0
- package/dist/esm/accountSwitch.js +74 -0
- package/dist/esm/accountSync.js +156 -0
- package/dist/esm/accountSyncTypes.js +4 -0
- package/dist/esm/accounts.js +1291 -0
- package/dist/esm/aggregation/EventAggregator.js +2034 -0
- package/dist/esm/aggregation/FrequencyTracker.js +69 -0
- package/dist/esm/aggregation/HeatmapTracker.js +89 -0
- package/dist/esm/aggregation/PatternExtractor.js +168 -0
- package/dist/esm/aggregation/costProvenance.js +31 -0
- package/dist/esm/aggregation/snapshot.js +161 -0
- package/dist/esm/aggregation/types.js +5 -0
- package/dist/esm/analytics/codeImpact.js +18 -0
- package/dist/esm/analytics/compactionLedger.js +37 -0
- package/dist/esm/analytics/qualityScore.js +112 -0
- package/dist/esm/autoSwitch.js +185 -0
- package/dist/esm/browser.js +34 -0
- package/dist/esm/claudeCredentialDoctor.js +239 -0
- package/dist/esm/claudeCredentialLedger.js +91 -0
- package/dist/esm/claudeCredentials.js +111 -0
- package/dist/esm/claudeLiveGuard.js +43 -0
- package/dist/esm/claudePaths.js +22 -0
- package/dist/esm/claudeProfiles.js +142 -0
- package/dist/esm/claudeRefreshLock.js +106 -0
- package/dist/esm/codexAuth.js +101 -0
- package/dist/esm/codexCredentialDoctor.js +265 -0
- package/dist/esm/codexCredentialStore.js +164 -0
- package/dist/esm/codexKeepAlive.js +112 -0
- package/dist/esm/codexPaths.js +172 -0
- package/dist/esm/codexProfiles.js +1251 -0
- package/dist/esm/codexQuota.js +564 -0
- package/dist/esm/codexQuotaWatcher.js +363 -0
- package/dist/esm/context/composer.js +71 -0
- package/dist/esm/context/sessionContext.js +546 -0
- package/dist/esm/credentialIO.js +283 -0
- package/dist/esm/credentials.js +49 -0
- package/dist/esm/doctor.js +305 -0
- package/dist/esm/ensureDefaultAccounts.js +62 -0
- package/dist/esm/errorHistory.js +56 -0
- package/dist/esm/extractors/errorTaxonomy.js +43 -0
- package/dist/esm/extractors/gatherAssets.js +32 -0
- package/dist/esm/extractors/sessionAssets.js +176 -0
- package/dist/esm/extractors/sources/claudeAssets.js +158 -0
- package/dist/esm/extractors/sources/codexAssets.js +234 -0
- package/dist/esm/extractors/tokenUsage.js +41 -0
- package/dist/esm/extractors/toolCall.js +142 -0
- package/dist/esm/failingTools.js +65 -0
- package/dist/esm/fileHead.js +27 -0
- package/dist/esm/formatters/eventHighlighter.js +332 -0
- package/dist/esm/formatters/noiseClassifier.js +209 -0
- package/dist/esm/formatters/sessionDump.js +328 -0
- package/dist/esm/formatters/toolSummary.js +226 -0
- package/dist/esm/formatting.js +82 -0
- package/dist/esm/handoffUrl.js +23 -0
- package/dist/esm/historicalStore.js +305 -0
- package/dist/esm/humanPrompt.js +88 -0
- package/dist/esm/index.js +213 -0
- package/dist/esm/keepAlive.js +189 -0
- package/dist/esm/liveCredentialGuard.js +33 -0
- package/dist/esm/modelAliases.js +51 -0
- package/dist/esm/modelCatalog.js +139 -0
- package/dist/esm/modelContext.js +280 -0
- package/dist/esm/modelInfo.js +832 -0
- package/dist/esm/multiProviderQuotaService.js +450 -0
- package/dist/esm/node.js +46 -0
- package/dist/esm/observedContextWindows.js +165 -0
- package/dist/esm/observedSessionCollector.js +939 -0
- package/dist/esm/parsers/changelogParser.js +71 -0
- package/dist/esm/parsers/codexHistory.js +89 -0
- package/dist/esm/parsers/codexParser.js +906 -0
- package/dist/esm/parsers/debugLogParser.js +126 -0
- package/dist/esm/parsers/jsonl.js +66 -0
- package/dist/esm/parsers/mcpToolName.js +28 -0
- package/dist/esm/parsers/openCodeParser.js +625 -0
- package/dist/esm/parsers/planExtractor.js +335 -0
- package/dist/esm/parsers/sessionActivityDetector.js +246 -0
- package/dist/esm/parsers/sessionPathResolver.js +713 -0
- package/dist/esm/parsers/subagentScanner.js +360 -0
- package/dist/esm/parsers/subagentStatsCache.js +45 -0
- package/dist/esm/parsers/subagentTraceParser.js +325 -0
- package/dist/esm/paths.js +134 -0
- package/dist/esm/peakHours.js +194 -0
- package/dist/esm/phrases.js +1330 -0
- package/dist/esm/pricingCatalog.js +247 -0
- package/dist/esm/processDetection.js +200 -0
- package/dist/esm/projectMigration.js +59 -0
- package/dist/esm/promptHistory.js +1221 -0
- package/dist/esm/providerFailure.js +362 -0
- package/dist/esm/providerIds.js +6 -0
- package/dist/esm/providerQuota.js +1 -0
- package/dist/esm/providerServiceStatus.js +145 -0
- package/dist/esm/providerServiceStatusTypes.js +1 -0
- package/dist/esm/providerStatus.js +117 -0
- package/dist/esm/providers/claudeCode.js +575 -0
- package/dist/esm/providers/claudeListing.js +121 -0
- package/dist/esm/providers/codex.js +1034 -0
- package/dist/esm/providers/codexDatabase.js +297 -0
- package/dist/esm/providers/codexRollouts.js +215 -0
- package/dist/esm/providers/detect.js +185 -0
- package/dist/esm/providers/diagnostics.js +83 -0
- package/dist/esm/providers/directoryListingCache.js +206 -0
- package/dist/esm/providers/factory.js +54 -0
- package/dist/esm/providers/openCode.js +2008 -0
- package/dist/esm/providers/openCodeDatabase.js +419 -0
- package/dist/esm/providers/rolloutWalker.js +252 -0
- package/dist/esm/providers/types.js +12 -0
- package/dist/esm/quota.js +182 -0
- package/dist/esm/quotaHistory.js +364 -0
- package/dist/esm/quotaPoller.js +230 -0
- package/dist/esm/quotaPresentation.js +97 -0
- package/dist/esm/quotaResolver.js +231 -0
- package/dist/esm/quotaSnapshots.js +130 -0
- package/dist/esm/quotaThresholds.js +85 -0
- package/dist/esm/readers/decisions.js +24 -0
- package/dist/esm/readers/handoff.js +20 -0
- package/dist/esm/readers/helpers.js +27 -0
- package/dist/esm/readers/history.js +9 -0
- package/dist/esm/readers/notes.js +48 -0
- package/dist/esm/readers/plans.js +225 -0
- package/dist/esm/readers/tasks.js +19 -0
- package/dist/esm/report/htmlHelpers.js +280 -0
- package/dist/esm/report/htmlReportGenerator.js +704 -0
- package/dist/esm/report/index.js +7 -0
- package/dist/esm/report/logo.js +2 -0
- package/dist/esm/report/openBrowser.js +19 -0
- package/dist/esm/report/sessionReportInputs.js +33 -0
- package/dist/esm/report/transcriptParser.js +83 -0
- package/dist/esm/report/types.js +4 -0
- package/dist/esm/schemas/accountManager.js +204 -0
- package/dist/esm/schemas/accountStatus.js +25 -0
- package/dist/esm/schemas/assistantTurn.js +74 -0
- package/dist/esm/schemas/index.js +25 -0
- package/dist/esm/schemas/observedSessionV1.js +77 -0
- package/dist/esm/schemas/providerFailure.js +37 -0
- package/dist/esm/schemas/quota.js +132 -0
- package/dist/esm/schemas/quotaHistory.js +40 -0
- package/dist/esm/schemas/sessionEvent.js +216 -0
- package/dist/esm/schemas/stateFile.js +75 -0
- package/dist/esm/schemas/tokenEstimation.js +7 -0
- package/dist/esm/schemas/transcript.js +86 -0
- package/dist/esm/schemas/usageNormalization.js +38 -0
- package/dist/esm/search/advancedFilter.js +197 -0
- package/dist/esm/search/sessionSearch.js +66 -0
- package/dist/esm/sessionFingerprint.js +40 -0
- package/dist/esm/sessionMonitor.js +198 -0
- package/dist/esm/sessionPreviews.js +522 -0
- package/dist/esm/sessionPromptHistory.js +254 -0
- package/dist/esm/sessionPromptSignals.js +364 -0
- package/dist/esm/sessionStats.js +160 -0
- package/dist/esm/sessionTokenTotals.js +98 -0
- package/dist/esm/sessionTranscripts.js +49 -0
- package/dist/esm/stateFile.js +119 -0
- package/dist/esm/statusline/BurnRateCalculator.js +40 -0
- package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
- package/dist/esm/statusline/formatter.js +122 -0
- package/dist/esm/statusline/index.js +8 -0
- package/dist/esm/terminalSync.js +95 -0
- package/dist/esm/testing/credentialGuard.js +197 -0
- package/dist/esm/testing/index.js +12 -0
- package/dist/esm/testing/sessionLogFixtures.js +128 -0
- package/dist/esm/tokenEstimation.js +67 -0
- package/dist/esm/tokenSummary.js +113 -0
- package/dist/esm/toolCategory.js +82 -0
- package/dist/esm/transcript.js +257 -0
- package/dist/esm/turns/assistantTurn.js +373 -0
- package/dist/esm/types/codex.js +23 -0
- package/dist/esm/types/decisionLog.js +5 -0
- package/dist/esm/types/historicalData.js +47 -0
- package/dist/esm/types/knowledgeNote.js +15 -0
- package/dist/esm/types/observedSessionV1.js +257 -0
- package/dist/esm/types/opencode.js +12 -0
- package/dist/esm/types/plan.js +7 -0
- package/dist/esm/types/sessionEvent.js +10 -0
- package/dist/esm/types/taskPersistence.js +12 -0
- package/dist/esm/usage/billingBlocks.js +148 -0
- package/dist/esm/usage/claudeUsageDedupe.js +189 -0
- package/dist/esm/usage/importSessionHistory.js +84 -0
- package/dist/esm/usage/subagentUsage.js +18 -0
- package/dist/esm/usage/usageEvents.js +402 -0
- package/dist/esm/usage/usageReports.js +202 -0
- package/dist/esm/usageNormalization.js +95 -0
- package/dist/esm/watchers/eventBridge.js +219 -0
- package/dist/esm/watchers/factory.js +63 -0
- package/dist/esm/watchers/jsonlTail.js +289 -0
- package/dist/esm/watchers/jsonlWatcher.js +435 -0
- package/dist/esm/watchers/providerReaderWatcher.js +118 -0
- package/dist/esm/watchers/sqliteWatcher.js +285 -0
- package/dist/esm/watchers/types.js +4 -0
- package/dist/esm/workspaceRoots.js +56 -0
- package/dist/esm/writers/atomic.js +375 -0
- package/dist/esm/writers/decisions.js +51 -0
- package/dist/esm/writers/notes.js +46 -0
- package/dist/esm/writers/tasks.js +63 -0
- package/dist/esm/zaiQuota.js +323 -0
- package/dist/esm/zaiQuotaApi.js +271 -0
- package/dist/esm/zaiQuotaWatcher.js +265 -0
- package/dist/fileHead.d.ts +22 -0
- package/dist/fileHead.js +33 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +49 -15
- package/dist/node.d.ts +13 -1
- package/dist/node.js +43 -2
- package/dist/parsers/codexHistory.js +2 -2
- package/dist/parsers/codexParser.d.ts +12 -0
- package/dist/parsers/codexParser.js +34 -12
- package/dist/parsers/debugLogParser.js +2 -2
- package/dist/parsers/sessionPathResolver.js +5 -4
- package/dist/promptHistory.d.ts +32 -0
- package/dist/promptHistory.js +115 -59
- package/dist/providerFailure.d.ts +12 -6
- package/dist/providerFailure.js +45 -0
- package/dist/providers/claudeCode.js +7 -99
- package/dist/providers/claudeListing.d.ts +22 -0
- package/dist/providers/claudeListing.js +158 -0
- package/dist/providers/codex.d.ts +3 -1
- package/dist/providers/codex.js +26 -227
- package/dist/providers/codexRollouts.d.ts +44 -0
- package/dist/providers/codexRollouts.js +261 -0
- package/dist/providers/detect.js +4 -4
- package/dist/readers/plans.js +3 -3
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.js +7 -2
- package/dist/schemas/providerFailure.d.ts +154 -0
- package/dist/schemas/providerFailure.js +40 -0
- package/dist/sessionPromptHistory.d.ts +12 -7
- package/dist/sessionPromptHistory.js +15 -3
- package/dist/sessionPromptSignals.d.ts +9 -0
- package/dist/sessionPromptSignals.js +13 -0
- package/dist/testing/credentialGuard.d.ts +23 -0
- package/dist/testing/credentialGuard.js +233 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +22 -0
- package/dist/testing/sessionLogFixtures.d.ts +28 -0
- package/dist/testing/sessionLogFixtures.js +169 -0
- package/dist/tokenEstimation.d.ts +2 -0
- package/dist/tokenEstimation.js +21 -11
- package/dist/toolCategory.d.ts +18 -0
- package/dist/toolCategory.js +86 -0
- package/dist/types/codex.d.ts +4 -0
- package/dist/usage/usageEvents.d.ts +16 -0
- package/dist/usage/usageEvents.js +53 -9
- package/dist/watchers/jsonlTail.d.ts +26 -1
- package/dist/watchers/jsonlTail.js +136 -28
- package/dist/watchers/jsonlWatcher.js +7 -0
- package/dist/watchers/providerReaderWatcher.js +1 -0
- package/dist/watchers/types.d.ts +6 -0
- package/dist/workspaceRoots.d.ts +23 -0
- package/dist/workspaceRoots.js +93 -0
- package/package.json +22 -2
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
const LOCK_STALE_MS = 10_000;
|
|
5
|
+
/**
|
|
6
|
+
* How long to wait for a lock that is making no progress.
|
|
7
|
+
*
|
|
8
|
+
* This budget detects a wedged holder, not a long queue. It is measured from the
|
|
9
|
+
* last time the lock changed hands rather than from the first attempt, because a
|
|
10
|
+
* writer queued behind other healthy writers is waiting, not failing — see
|
|
11
|
+
* {@link acquireLock}.
|
|
12
|
+
*/
|
|
13
|
+
const LOCK_NO_PROGRESS_MS = 3_000;
|
|
14
|
+
/**
|
|
15
|
+
* A live async holder heartbeats every LOCK_STALE_MS / 3; an mtime frozen this
|
|
16
|
+
* long means the heartbeat timer is gone — the owner is dead (possibly with its
|
|
17
|
+
* PID recycled by the OS) or wedged beyond recovery. Reclaim regardless of the
|
|
18
|
+
* PID probe, which cannot tell a recycled PID from a live owner. Sync holders
|
|
19
|
+
* have no heartbeat; they usually hold locks for milliseconds, and even the
|
|
20
|
+
* slowest (a keychain read through `security` behind the auth-swap lock, up to
|
|
21
|
+
* a few seconds) stays far under this ceiling.
|
|
22
|
+
*/
|
|
23
|
+
const LOCK_ABANDONED_MS = 120_000;
|
|
24
|
+
/**
|
|
25
|
+
* Hard ceiling on how long a synchronous waiter blocks, regardless of queue
|
|
26
|
+
* progress. Progress resets are correct for async waiters, but a sync waiter
|
|
27
|
+
* blocks its process's event loop while queued, so healthy-queue churn must not
|
|
28
|
+
* extend the block indefinitely.
|
|
29
|
+
*/
|
|
30
|
+
const SYNC_LOCK_MAX_WAIT_MS = 15_000;
|
|
31
|
+
const LOCK_WAIT_INTERVAL_MS = 15;
|
|
32
|
+
const lockSleepView = new Int32Array(new SharedArrayBuffer(4));
|
|
33
|
+
/**
|
|
34
|
+
* Windows refuses to replace a file another process holds open (antivirus
|
|
35
|
+
* scanners, an editor extension re-reading `auth.json`) with EPERM/EBUSY/EACCES
|
|
36
|
+
* even though the rename would succeed a few milliseconds later. Retry with a
|
|
37
|
+
* short back-off on that platform only; other platforms surface the error.
|
|
38
|
+
*/
|
|
39
|
+
const WIN32_RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
40
|
+
const WIN32_RENAME_MAX_ATTEMPTS = 10;
|
|
41
|
+
function renameRetryDelayMs(attempt) {
|
|
42
|
+
return Math.min(320, 20 * 2 ** attempt);
|
|
43
|
+
}
|
|
44
|
+
function shouldRetryRename(error, attempt) {
|
|
45
|
+
if (process.platform !== 'win32')
|
|
46
|
+
return false;
|
|
47
|
+
if (attempt >= WIN32_RENAME_MAX_ATTEMPTS - 1)
|
|
48
|
+
return false;
|
|
49
|
+
const code = error.code ?? '';
|
|
50
|
+
return WIN32_RENAME_RETRY_CODES.has(code);
|
|
51
|
+
}
|
|
52
|
+
export function renameSyncWithRetry(from, to) {
|
|
53
|
+
for (let attempt = 0;; attempt++) {
|
|
54
|
+
try {
|
|
55
|
+
fs.renameSync(from, to);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (!shouldRetryRename(error, attempt))
|
|
60
|
+
throw error;
|
|
61
|
+
Atomics.wait(lockSleepView, 0, 0, renameRetryDelayMs(attempt));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function renameWithRetry(from, to) {
|
|
66
|
+
for (let attempt = 0;; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
await fs.promises.rename(from, to);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!shouldRetryRename(error, attempt))
|
|
73
|
+
throw error;
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, renameRetryDelayMs(attempt)));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function syncDirectory(directory) {
|
|
79
|
+
let handle;
|
|
80
|
+
try {
|
|
81
|
+
handle = await fs.promises.open(directory, 'r');
|
|
82
|
+
await handle.sync();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const code = error.code;
|
|
86
|
+
if (!['EISDIR', 'EINVAL', 'ENOSYS', 'EPERM', 'EACCES'].includes(code ?? ''))
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await handle?.close().catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function syncDirectorySync(directory) {
|
|
94
|
+
let descriptor;
|
|
95
|
+
try {
|
|
96
|
+
descriptor = fs.openSync(directory, 'r');
|
|
97
|
+
fs.fsyncSync(descriptor);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const code = error.code;
|
|
101
|
+
if (!['EISDIR', 'EINVAL', 'ENOSYS', 'EPERM', 'EACCES'].includes(code ?? ''))
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
if (descriptor !== undefined)
|
|
106
|
+
fs.closeSync(descriptor);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function atomicWriteJson(filePath, value) {
|
|
110
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
111
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
112
|
+
let handle;
|
|
113
|
+
try {
|
|
114
|
+
handle = await fs.promises.open(tempPath, 'wx', 0o600);
|
|
115
|
+
await handle.writeFile(JSON.stringify(value, null, 2), 'utf8');
|
|
116
|
+
await handle.sync();
|
|
117
|
+
await handle.close();
|
|
118
|
+
handle = undefined;
|
|
119
|
+
await renameWithRetry(tempPath, filePath);
|
|
120
|
+
await syncDirectory(path.dirname(filePath));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
await handle?.close().catch(() => undefined);
|
|
124
|
+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function atomicWriteJsonSync(filePath, value) {
|
|
129
|
+
atomicWriteFileSync(filePath, JSON.stringify(value, null, 2));
|
|
130
|
+
}
|
|
131
|
+
/** Raw-string sibling of {@link atomicWriteJsonSync} for non-JSON payloads. */
|
|
132
|
+
export function atomicWriteFileSync(filePath, content, mode = 0o600) {
|
|
133
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
134
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
135
|
+
let descriptor;
|
|
136
|
+
try {
|
|
137
|
+
descriptor = fs.openSync(tempPath, 'wx', mode);
|
|
138
|
+
fs.writeFileSync(descriptor, content, { encoding: 'utf8' });
|
|
139
|
+
fs.fsyncSync(descriptor);
|
|
140
|
+
fs.closeSync(descriptor);
|
|
141
|
+
descriptor = undefined;
|
|
142
|
+
renameSyncWithRetry(tempPath, filePath);
|
|
143
|
+
syncDirectorySync(path.dirname(filePath));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (descriptor !== undefined)
|
|
147
|
+
fs.closeSync(descriptor);
|
|
148
|
+
fs.rmSync(tempPath, { force: true });
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Serializes read-modify-write cycles across Sidekick processes. */
|
|
153
|
+
export async function updateJsonStoreAtomic(filePath, createEmpty, update) {
|
|
154
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
155
|
+
const lockPath = `${filePath}.lock`;
|
|
156
|
+
const lock = await acquireLock(lockPath);
|
|
157
|
+
try {
|
|
158
|
+
let latest = createEmpty();
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = await fs.promises.readFile(filePath, 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code !== 'ENOENT')
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
if (raw !== undefined) {
|
|
168
|
+
try {
|
|
169
|
+
latest = JSON.parse(raw);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
const corruptPath = `${filePath}.corrupt-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
173
|
+
await fs.promises.rename(filePath, corruptPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const next = await update(latest);
|
|
177
|
+
await atomicWriteJson(filePath, next);
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await releaseLock(lockPath, lock);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Waits for exclusive ownership of `lockPath`.
|
|
186
|
+
*
|
|
187
|
+
* The wait budget is reset every time the lock changes hands. A flat budget from
|
|
188
|
+
* the first attempt conflated two unrelated conditions — a wedged holder and a
|
|
189
|
+
* queue of healthy writers — so under real contention the writers at the back of
|
|
190
|
+
* the queue failed even though every lock was being acquired and released
|
|
191
|
+
* correctly. Each cycle costs three fsyncs (lock file, temp file, directory), so
|
|
192
|
+
* on slow storage a burst of concurrent captures drained slower than the budget.
|
|
193
|
+
*
|
|
194
|
+
* A stuck holder keeps the same token, so it makes no progress and still trips
|
|
195
|
+
* the timeout; the stale-reclaim path below still handles an owner that died.
|
|
196
|
+
*/
|
|
197
|
+
async function acquireLock(lockPath) {
|
|
198
|
+
let deadline = Date.now() + LOCK_NO_PROGRESS_MS;
|
|
199
|
+
let observedOwner = null;
|
|
200
|
+
while (true) {
|
|
201
|
+
try {
|
|
202
|
+
const handle = await fs.promises.open(lockPath, 'wx', 0o600);
|
|
203
|
+
const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
|
|
204
|
+
await handle.writeFile(token, 'utf8');
|
|
205
|
+
await handle.sync();
|
|
206
|
+
const heartbeat = setInterval(() => {
|
|
207
|
+
fs.promises.utimes(lockPath, new Date(), new Date()).catch(() => undefined);
|
|
208
|
+
}, Math.floor(LOCK_STALE_MS / 3));
|
|
209
|
+
heartbeat.unref();
|
|
210
|
+
return { handle, token, heartbeat };
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code !== 'EEXIST')
|
|
214
|
+
throw error;
|
|
215
|
+
// The token identifies the current holder. mtime cannot stand in for it:
|
|
216
|
+
// the heartbeat keeps touching the file, so even a wedged holder looks
|
|
217
|
+
// fresh. A different token means the queue advanced.
|
|
218
|
+
const currentOwner = await fs.promises.readFile(lockPath, 'utf8').catch(() => null);
|
|
219
|
+
if (currentOwner !== null && currentOwner !== observedOwner) {
|
|
220
|
+
observedOwner = currentOwner;
|
|
221
|
+
deadline = Date.now() + LOCK_NO_PROGRESS_MS;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const stat = await fs.promises.stat(lockPath);
|
|
225
|
+
const frozenMs = Date.now() - stat.mtimeMs;
|
|
226
|
+
if (frozenMs > LOCK_STALE_MS && !lockOwnerAlive(currentOwner, frozenMs)) {
|
|
227
|
+
await fs.promises.rm(lockPath, { force: true });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// The other writer released between checks.
|
|
232
|
+
}
|
|
233
|
+
if (Date.now() >= deadline) {
|
|
234
|
+
throw new Error(`Timed out waiting for store lock: ${lockPath} (no progress for ${LOCK_NO_PROGRESS_MS}ms)`);
|
|
235
|
+
}
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function releaseLock(lockPath, lock) {
|
|
241
|
+
clearInterval(lock.heartbeat);
|
|
242
|
+
await lock.handle.close().catch(() => undefined);
|
|
243
|
+
try {
|
|
244
|
+
const currentToken = await fs.promises.readFile(lockPath, 'utf8');
|
|
245
|
+
if (currentToken === lock.token)
|
|
246
|
+
await fs.promises.rm(lockPath, { force: true });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// The lock was already released or replaced; never delete another owner's lock.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether the recorded owner of a stale lock should still be treated as alive.
|
|
254
|
+
* An mtime frozen past {@link LOCK_ABANDONED_MS} overrides the PID probe — see
|
|
255
|
+
* the constant for why the probe alone cannot be trusted at that age.
|
|
256
|
+
*/
|
|
257
|
+
function lockOwnerAlive(owner, frozenMs) {
|
|
258
|
+
if (frozenMs > LOCK_ABANDONED_MS)
|
|
259
|
+
return false;
|
|
260
|
+
const ownerPid = Number.parseInt((owner ?? '').split(':', 1)[0], 10);
|
|
261
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0)
|
|
262
|
+
return false;
|
|
263
|
+
try {
|
|
264
|
+
process.kill(ownerPid, 0);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
return error.code === 'EPERM';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Synchronous counterpart of {@link acquireLock}, sharing its lock-file
|
|
273
|
+
* protocol so sync and async waiters exclude each other across processes.
|
|
274
|
+
*
|
|
275
|
+
* Two timeout rules apply instead of one: the same progress-based budget as the
|
|
276
|
+
* async path (a token that never changes trips it in LOCK_NO_PROGRESS_MS), plus
|
|
277
|
+
* an absolute {@link SYNC_LOCK_MAX_WAIT_MS} ceiling, because a sync waiter
|
|
278
|
+
* blocks its event loop while queued. Sync holders take no heartbeat — their
|
|
279
|
+
* critical sections must stay well under LOCK_STALE_MS (in-repo users are
|
|
280
|
+
* local-filesystem read-modify-writes, plus keychain reads behind the
|
|
281
|
+
* auth-swap locks that can take a few seconds).
|
|
282
|
+
*
|
|
283
|
+
* The caller must ensure `dirname(lockPath)` exists — a missing parent
|
|
284
|
+
* surfaces as a raw ENOENT, not a timeout.
|
|
285
|
+
*/
|
|
286
|
+
export function withFileLockSync(lockPath, operation) {
|
|
287
|
+
const token = acquireLockSync(lockPath);
|
|
288
|
+
try {
|
|
289
|
+
return operation();
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
try {
|
|
293
|
+
if (fs.readFileSync(lockPath, 'utf8') === token)
|
|
294
|
+
fs.rmSync(lockPath, { force: true });
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// The lock was already released or replaced; never delete another owner's lock.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function acquireLockSync(lockPath) {
|
|
302
|
+
const start = Date.now();
|
|
303
|
+
let deadline = start + LOCK_NO_PROGRESS_MS;
|
|
304
|
+
let observedOwner = null;
|
|
305
|
+
while (true) {
|
|
306
|
+
try {
|
|
307
|
+
const descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
|
308
|
+
const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
|
|
309
|
+
fs.writeFileSync(descriptor, token, 'utf8');
|
|
310
|
+
fs.fsyncSync(descriptor);
|
|
311
|
+
fs.closeSync(descriptor);
|
|
312
|
+
return token;
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (error.code !== 'EEXIST')
|
|
316
|
+
throw error;
|
|
317
|
+
let currentOwner = null;
|
|
318
|
+
try {
|
|
319
|
+
currentOwner = fs.readFileSync(lockPath, 'utf8');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// The other writer released between checks.
|
|
323
|
+
}
|
|
324
|
+
if (currentOwner !== null && currentOwner !== observedOwner) {
|
|
325
|
+
observedOwner = currentOwner;
|
|
326
|
+
deadline = Date.now() + LOCK_NO_PROGRESS_MS;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const stat = fs.statSync(lockPath);
|
|
330
|
+
const frozenMs = Date.now() - stat.mtimeMs;
|
|
331
|
+
if (frozenMs > LOCK_STALE_MS && !lockOwnerAlive(currentOwner, frozenMs)) {
|
|
332
|
+
fs.rmSync(lockPath, { force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// The other writer released between checks.
|
|
337
|
+
}
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
if (now >= deadline) {
|
|
340
|
+
throw new Error(`Timed out waiting for store lock: ${lockPath} (no progress for ${LOCK_NO_PROGRESS_MS}ms)`);
|
|
341
|
+
}
|
|
342
|
+
if (now - start >= SYNC_LOCK_MAX_WAIT_MS) {
|
|
343
|
+
throw new Error(`Timed out waiting for store lock: ${lockPath} (blocked synchronously for ${SYNC_LOCK_MAX_WAIT_MS}ms)`);
|
|
344
|
+
}
|
|
345
|
+
Atomics.wait(lockSleepView, 0, 0, LOCK_WAIT_INTERVAL_MS);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Synchronous mirror of {@link updateJsonStoreAtomic}. */
|
|
350
|
+
export function updateJsonStoreAtomicSync(filePath, createEmpty, update) {
|
|
351
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
352
|
+
return withFileLockSync(`${filePath}.lock`, () => {
|
|
353
|
+
let latest = createEmpty();
|
|
354
|
+
let raw;
|
|
355
|
+
try {
|
|
356
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error.code !== 'ENOENT')
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
if (raw !== undefined) {
|
|
363
|
+
try {
|
|
364
|
+
latest = JSON.parse(raw);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
const corruptPath = `${filePath}.corrupt-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
368
|
+
fs.renameSync(filePath, corruptPath);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const next = update(latest);
|
|
372
|
+
atomicWriteJsonSync(filePath, next);
|
|
373
|
+
return next;
|
|
374
|
+
});
|
|
375
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { getProjectDataPath, resolveProjectIdentity } from '../paths';
|
|
3
|
+
import { migrateLegacyProjectStores } from '../projectMigration';
|
|
4
|
+
import { DECISION_LOG_SCHEMA_VERSION, } from '../types/decisionLog';
|
|
5
|
+
import { updateJsonStoreAtomic } from './atomic';
|
|
6
|
+
function emptyDecisionStore() {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: DECISION_LOG_SCHEMA_VERSION,
|
|
9
|
+
decisions: {},
|
|
10
|
+
lastSessionId: 'cli',
|
|
11
|
+
lastSaved: new Date().toISOString(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function decisionFingerprint(decision) {
|
|
15
|
+
return `${decision.source}::${decision.description.toLowerCase().trim()}`;
|
|
16
|
+
}
|
|
17
|
+
export async function addDecision(cwd, description, options = {}) {
|
|
18
|
+
const project = resolveProjectIdentity(cwd);
|
|
19
|
+
migrateLegacyProjectStores(project);
|
|
20
|
+
const filePath = getProjectDataPath(project.canonicalSlug, 'decisions');
|
|
21
|
+
const decision = {
|
|
22
|
+
id: crypto.randomUUID(),
|
|
23
|
+
description: description.trim(),
|
|
24
|
+
rationale: options.rationale?.trim() || 'Captured from the Sidekick CLI.',
|
|
25
|
+
chosenOption: options.chosenOption?.trim() || description.trim(),
|
|
26
|
+
alternatives: options.alternatives,
|
|
27
|
+
source: options.source ?? 'text_pattern',
|
|
28
|
+
sessionId: 'cli',
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
tags: options.tags,
|
|
31
|
+
};
|
|
32
|
+
let added = false;
|
|
33
|
+
let persisted = decision;
|
|
34
|
+
await updateJsonStoreAtomic(filePath, emptyDecisionStore, (store) => {
|
|
35
|
+
const fingerprint = decisionFingerprint(decision);
|
|
36
|
+
const duplicate = Object.values(store.decisions).find((entry) => decisionFingerprint(entry) === fingerprint);
|
|
37
|
+
if (duplicate) {
|
|
38
|
+
persisted = duplicate;
|
|
39
|
+
return store;
|
|
40
|
+
}
|
|
41
|
+
added = true;
|
|
42
|
+
return {
|
|
43
|
+
...store,
|
|
44
|
+
schemaVersion: DECISION_LOG_SCHEMA_VERSION,
|
|
45
|
+
decisions: { ...store.decisions, [decision.id]: decision },
|
|
46
|
+
lastSessionId: 'cli',
|
|
47
|
+
lastSaved: decision.timestamp,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return { decision: persisted, added };
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { getProjectDataPath, resolveProjectIdentity } from '../paths';
|
|
3
|
+
import { migrateLegacyProjectStores } from '../projectMigration';
|
|
4
|
+
import { KNOWLEDGE_NOTE_SCHEMA_VERSION, } from '../types/knowledgeNote';
|
|
5
|
+
import { updateJsonStoreAtomic } from './atomic';
|
|
6
|
+
function emptyNoteStore() {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: KNOWLEDGE_NOTE_SCHEMA_VERSION,
|
|
9
|
+
notesByFile: {},
|
|
10
|
+
lastSaved: new Date().toISOString(),
|
|
11
|
+
totalNotes: 0,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function addNote(cwd, content, options = {}) {
|
|
15
|
+
const project = resolveProjectIdentity(cwd);
|
|
16
|
+
migrateLegacyProjectStores(project);
|
|
17
|
+
const filePath = getProjectDataPath(project.canonicalSlug, 'knowledge-notes');
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
const notePath = options.filePath?.trim() || '.';
|
|
20
|
+
const note = {
|
|
21
|
+
id: crypto.randomUUID(),
|
|
22
|
+
noteType: options.noteType ?? 'tip',
|
|
23
|
+
content: content.trim(),
|
|
24
|
+
title: options.title,
|
|
25
|
+
filePath: notePath,
|
|
26
|
+
source: 'manual',
|
|
27
|
+
status: 'active',
|
|
28
|
+
importance: options.importance ?? 'medium',
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
lastReviewedAt: now,
|
|
32
|
+
tags: options.tags,
|
|
33
|
+
};
|
|
34
|
+
await updateJsonStoreAtomic(filePath, emptyNoteStore, (store) => {
|
|
35
|
+
const existing = store.notesByFile[notePath] ?? [];
|
|
36
|
+
const notesByFile = { ...store.notesByFile, [notePath]: [...existing, note] };
|
|
37
|
+
return {
|
|
38
|
+
...store,
|
|
39
|
+
schemaVersion: KNOWLEDGE_NOTE_SCHEMA_VERSION,
|
|
40
|
+
notesByFile,
|
|
41
|
+
totalNotes: Object.values(notesByFile).reduce((sum, notes) => sum + notes.length, 0),
|
|
42
|
+
lastSaved: now,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
return note;
|
|
46
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { getProjectDataPath, resolveProjectIdentity } from '../paths';
|
|
3
|
+
import { migrateLegacyProjectStores } from '../projectMigration';
|
|
4
|
+
import { TASK_PERSISTENCE_SCHEMA_VERSION, } from '../types/taskPersistence';
|
|
5
|
+
import { updateJsonStoreAtomic } from './atomic';
|
|
6
|
+
function emptyTaskStore() {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: TASK_PERSISTENCE_SCHEMA_VERSION,
|
|
9
|
+
tasks: {},
|
|
10
|
+
lastSessionId: 'cli',
|
|
11
|
+
sessionCount: 0,
|
|
12
|
+
lastSaved: new Date().toISOString(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function addTask(cwd, subject, options = {}) {
|
|
16
|
+
const project = resolveProjectIdentity(cwd);
|
|
17
|
+
migrateLegacyProjectStores(project);
|
|
18
|
+
const filePath = getProjectDataPath(project.canonicalSlug, 'tasks');
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
const task = {
|
|
21
|
+
taskId: crypto.randomUUID(),
|
|
22
|
+
subject: subject.trim(),
|
|
23
|
+
description: options.description,
|
|
24
|
+
status: 'pending',
|
|
25
|
+
createdAt: now,
|
|
26
|
+
updatedAt: now,
|
|
27
|
+
toolCallCount: 0,
|
|
28
|
+
blockedBy: [],
|
|
29
|
+
blocks: [],
|
|
30
|
+
sessionOrigin: 'cli',
|
|
31
|
+
carriedOver: false,
|
|
32
|
+
sessionAge: 0,
|
|
33
|
+
tags: options.tags,
|
|
34
|
+
};
|
|
35
|
+
await updateJsonStoreAtomic(filePath, emptyTaskStore, (store) => ({
|
|
36
|
+
...store,
|
|
37
|
+
schemaVersion: TASK_PERSISTENCE_SCHEMA_VERSION,
|
|
38
|
+
tasks: { ...store.tasks, [task.taskId]: task },
|
|
39
|
+
lastSessionId: store.lastSessionId || 'cli',
|
|
40
|
+
lastSaved: now,
|
|
41
|
+
}));
|
|
42
|
+
return task;
|
|
43
|
+
}
|
|
44
|
+
export async function completeTask(cwd, taskIdOrPrefix) {
|
|
45
|
+
const project = resolveProjectIdentity(cwd);
|
|
46
|
+
migrateLegacyProjectStores(project);
|
|
47
|
+
const filePath = getProjectDataPath(project.canonicalSlug, 'tasks');
|
|
48
|
+
let completed;
|
|
49
|
+
await updateJsonStoreAtomic(filePath, emptyTaskStore, (store) => {
|
|
50
|
+
const matches = Object.values(store.tasks).filter((task) => task.taskId === taskIdOrPrefix || task.taskId.startsWith(taskIdOrPrefix));
|
|
51
|
+
if (matches.length !== 1) {
|
|
52
|
+
throw new Error(matches.length === 0 ? `Task not found: ${taskIdOrPrefix}` : 'Task ID is ambiguous');
|
|
53
|
+
}
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
completed = { ...matches[0], status: 'completed', updatedAt: now };
|
|
56
|
+
return {
|
|
57
|
+
...store,
|
|
58
|
+
tasks: { ...store.tasks, [completed.taskId]: completed },
|
|
59
|
+
lastSaved: now,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return completed;
|
|
63
|
+
}
|