opencandle 0.10.0 → 0.11.1
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 +12 -9
- package/dist/analysts/orchestrator.d.ts +3 -4
- package/dist/analysts/orchestrator.js +34 -44
- package/dist/analysts/orchestrator.js.map +1 -1
- package/dist/cli-main.js +242 -9
- package/dist/cli-main.js.map +1 -1
- package/dist/config.d.ts +4 -9
- package/dist/config.js +7 -10
- package/dist/config.js.map +1 -1
- package/dist/doctor/report.js +1 -1
- package/dist/doctor/report.js.map +1 -1
- package/dist/infra/cache.d.ts +6 -0
- package/dist/infra/cache.js +16 -6
- package/dist/infra/cache.js.map +1 -1
- package/dist/infra/freshness.d.ts +21 -0
- package/dist/infra/freshness.js +119 -0
- package/dist/infra/freshness.js.map +1 -0
- package/dist/infra/market-calendar.d.ts +14 -0
- package/dist/infra/market-calendar.js +137 -0
- package/dist/infra/market-calendar.js.map +1 -0
- package/dist/infra/rate-limiter.js +1 -0
- package/dist/infra/rate-limiter.js.map +1 -1
- package/dist/market-state/daily-report.d.ts +1 -1
- package/dist/market-state/daily-report.js +5 -16
- package/dist/market-state/daily-report.js.map +1 -1
- package/dist/market-state/service.d.ts +0 -35
- package/dist/market-state/service.js +0 -63
- package/dist/market-state/service.js.map +1 -1
- package/dist/market-state/summaries.d.ts +8 -0
- package/dist/market-state/summaries.js +70 -0
- package/dist/market-state/summaries.js.map +1 -0
- package/dist/memory/sqlite.js +20 -19
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/onboarding/providers.d.ts +25 -1
- package/dist/onboarding/providers.js +26 -0
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/pi/opencandle-extension.js +57 -226
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-action-dedupe.d.ts +6 -0
- package/dist/pi/session-action-dedupe.js +126 -0
- package/dist/pi/session-action-dedupe.js.map +1 -0
- package/dist/pi/session-writer-lock.d.ts +26 -2
- package/dist/pi/session-writer-lock.js +230 -18
- package/dist/pi/session-writer-lock.js.map +1 -1
- package/dist/pi/setup.js +5 -5
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tui-session-coordinator.d.ts +15 -0
- package/dist/pi/tui-session-coordinator.js +283 -0
- package/dist/pi/tui-session-coordinator.js.map +1 -0
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/alpha-vantage.js +6 -0
- package/dist/providers/alpha-vantage.js.map +1 -1
- package/dist/providers/coingecko.js +1 -0
- package/dist/providers/coingecko.js.map +1 -1
- package/dist/providers/polymarket.d.ts +2 -0
- package/dist/providers/polymarket.js +163 -0
- package/dist/providers/polymarket.js.map +1 -0
- package/dist/providers/wrap-provider.js +5 -3
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.d.ts +3 -0
- package/dist/providers/yahoo-finance.js +206 -10
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/classify-intent.js +4 -5
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +16 -0
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/route-manifest.js +1 -2
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-llm-client.js +18 -3
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router-prompt.js +1 -1
- package/dist/routing/router.d.ts +1 -1
- package/dist/routing/router.js +309 -9
- package/dist/routing/router.js.map +1 -1
- package/dist/routing/slot-resolver.d.ts +1 -0
- package/dist/routing/slot-resolver.js +1 -1
- package/dist/routing/slot-resolver.js.map +1 -1
- package/dist/runtime/evidence.d.ts +1 -0
- package/dist/runtime/evidence.js.map +1 -1
- package/dist/runtime/numeric-claims.d.ts +23 -0
- package/dist/runtime/numeric-claims.js +100 -0
- package/dist/runtime/numeric-claims.js.map +1 -0
- package/dist/runtime/planning-evidence.js +1 -76
- package/dist/runtime/planning-evidence.js.map +1 -1
- package/dist/runtime/prompt-step.d.ts +10 -1
- package/dist/runtime/prompt-step.js +116 -2
- package/dist/runtime/prompt-step.js.map +1 -1
- package/dist/runtime/session-coordinator.d.ts +11 -1
- package/dist/runtime/session-coordinator.js +354 -62
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/validation.d.ts +3 -1
- package/dist/runtime/workflow-events.d.ts +1 -1
- package/dist/runtime/workflow-events.js.map +1 -1
- package/dist/runtime/workflow-types.d.ts +3 -0
- package/dist/runtime/workflow-types.js.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -0
- package/dist/tools/fundamentals/comps.js +20 -9
- package/dist/tools/fundamentals/comps.js.map +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +190 -76
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/macro/event-probabilities.d.ts +8 -0
- package/dist/tools/macro/event-probabilities.js +121 -0
- package/dist/tools/macro/event-probabilities.js.map +1 -0
- package/dist/tools/market/crypto-price.d.ts +4 -1
- package/dist/tools/market/crypto-price.js +10 -1
- package/dist/tools/market/crypto-price.js.map +1 -1
- package/dist/tools/market/screen-stocks.js +14 -3
- package/dist/tools/market/screen-stocks.js.map +1 -1
- package/dist/tools/market/stock-quote.d.ts +4 -1
- package/dist/tools/market/stock-quote.js +9 -4
- package/dist/tools/market/stock-quote.js.map +1 -1
- package/dist/tools/options/option-chain.d.ts +4 -1
- package/dist/tools/options/option-chain.js +12 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/daily-report.js +10 -4
- package/dist/tools/portfolio/daily-report.js.map +1 -1
- package/dist/tools/portfolio/risk-analysis.js +1 -1
- package/dist/tools/portfolio/risk-analysis.js.map +1 -1
- package/dist/tools/technical/backtest.d.ts +23 -5
- package/dist/tools/technical/backtest.js +131 -94
- package/dist/tools/technical/backtest.js.map +1 -1
- package/dist/tools/technical/indicators.js +1 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/types/fundamentals.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/market.d.ts +2 -0
- package/dist/types/options.d.ts +1 -0
- package/dist/types/prediction-markets.d.ts +13 -0
- package/dist/types/prediction-markets.js +2 -0
- package/dist/types/prediction-markets.js.map +1 -0
- package/gui/server/chat-event-adapter.ts +89 -5
- package/gui/server/http-routes.ts +661 -37
- package/gui/server/invoke-tool.ts +165 -16
- package/gui/server/live-chat-event-adapter.ts +37 -2
- package/gui/server/local-session-coordinator.ts +97 -0
- package/gui/server/market-state-api.ts +64 -48
- package/gui/server/projector.ts +132 -3
- package/gui/server/server.ts +51 -6
- package/gui/server/session-actions.ts +146 -45
- package/gui/server/session-entry-wait.ts +22 -3
- package/gui/server/tool-metadata.ts +3 -1
- package/gui/server/ws-hub.ts +77 -10
- package/gui/shared/chat-events.ts +10 -1
- package/gui/shared/event-reducer.ts +1 -0
- package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
- package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
- package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +13 -15
- package/src/analysts/orchestrator.ts +35 -56
- package/src/cli-main.ts +253 -13
- package/src/config.ts +12 -20
- package/src/doctor/report.ts +1 -1
- package/src/infra/cache.ts +24 -11
- package/src/infra/freshness.ts +165 -0
- package/src/infra/market-calendar.ts +193 -0
- package/src/infra/rate-limiter.ts +1 -0
- package/src/market-state/daily-report.ts +6 -16
- package/src/market-state/service.ts +0 -136
- package/src/market-state/summaries.ts +75 -0
- package/src/memory/sqlite.ts +23 -19
- package/src/onboarding/providers.ts +29 -1
- package/src/pi/opencandle-extension.ts +67 -270
- package/src/pi/session-action-dedupe.ts +155 -0
- package/src/pi/session-writer-lock.ts +290 -20
- package/src/pi/setup.ts +6 -6
- package/src/pi/tui-session-coordinator.ts +351 -0
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/alpha-vantage.ts +6 -0
- package/src/providers/coingecko.ts +2 -0
- package/src/providers/polymarket.ts +214 -0
- package/src/providers/wrap-provider.ts +5 -3
- package/src/providers/yahoo-finance.ts +339 -27
- package/src/routing/classify-intent.ts +4 -5
- package/src/routing/entity-extractor.ts +17 -0
- package/src/routing/route-manifest.ts +1 -2
- package/src/routing/router-llm-client.ts +31 -19
- package/src/routing/router-prompt.ts +1 -1
- package/src/routing/router.ts +379 -8
- package/src/routing/slot-resolver.ts +1 -1
- package/src/runtime/evidence.ts +1 -0
- package/src/runtime/numeric-claims.ts +108 -0
- package/src/runtime/planning-evidence.ts +6 -91
- package/src/runtime/prompt-step.ts +127 -2
- package/src/runtime/session-coordinator.ts +464 -74
- package/src/runtime/validation.ts +1 -1
- package/src/runtime/workflow-events.ts +2 -0
- package/src/runtime/workflow-types.ts +3 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/AGENTS.md +1 -1
- package/src/tools/fundamentals/comps.ts +25 -14
- package/src/tools/fundamentals/dcf.ts +217 -87
- package/src/tools/index.ts +3 -3
- package/src/tools/macro/event-probabilities.ts +141 -0
- package/src/tools/market/crypto-price.ts +14 -2
- package/src/tools/market/screen-stocks.ts +14 -3
- package/src/tools/market/stock-quote.ts +49 -43
- package/src/tools/options/option-chain.ts +16 -2
- package/src/tools/portfolio/daily-report.ts +10 -4
- package/src/tools/portfolio/risk-analysis.ts +1 -1
- package/src/tools/technical/backtest.ts +167 -108
- package/src/tools/technical/indicators.ts +1 -1
- package/src/types/fundamentals.ts +1 -0
- package/src/types/index.ts +1 -0
- package/src/types/market.ts +2 -0
- package/src/types/options.ts +1 -0
- package/src/types/prediction-markets.ts +13 -0
- package/dist/tools/portfolio/predictions.d.ts +0 -55
- package/dist/tools/portfolio/predictions.js +0 -422
- package/dist/tools/portfolio/predictions.js.map +0 -1
- package/gui/server/chat-run-session.ts +0 -16
- package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
- package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
- package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
- package/src/tools/portfolio/predictions.ts +0 -553
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { type SessionLockScopeSource, writerLockScopeForSession } from "./session-writer-lock.js";
|
|
4
|
+
|
|
5
|
+
interface AcceptedActionStore {
|
|
6
|
+
acceptedActionIds: string[];
|
|
7
|
+
pendingActions: PendingActionRecord[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PendingActionRecord {
|
|
11
|
+
id: string;
|
|
12
|
+
pendingAtMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pendingSessionActionTtlMs = 2 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export function hasAcceptedSessionAction(
|
|
18
|
+
sessionManager: SessionLockScopeSource,
|
|
19
|
+
actionId: string,
|
|
20
|
+
): boolean {
|
|
21
|
+
const normalizedActionId = actionId.trim();
|
|
22
|
+
if (!normalizedActionId) return false;
|
|
23
|
+
return readAcceptedActionStore(sessionManager).acceptedActionIds.includes(normalizedActionId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hasPendingSessionAction(
|
|
27
|
+
sessionManager: SessionLockScopeSource,
|
|
28
|
+
actionId: string,
|
|
29
|
+
): boolean {
|
|
30
|
+
const normalizedActionId = actionId.trim();
|
|
31
|
+
if (!normalizedActionId) return false;
|
|
32
|
+
return readAcceptedActionStore(sessionManager).pendingActions.some(
|
|
33
|
+
(record) => record.id === normalizedActionId,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function recordPendingSessionAction(
|
|
38
|
+
sessionManager: SessionLockScopeSource,
|
|
39
|
+
actionId: string,
|
|
40
|
+
): void {
|
|
41
|
+
const normalizedActionId = actionId.trim();
|
|
42
|
+
if (!normalizedActionId || hasAcceptedSessionAction(sessionManager, normalizedActionId)) return;
|
|
43
|
+
const storePath = acceptedActionStorePath(sessionManager);
|
|
44
|
+
if (!storePath) return;
|
|
45
|
+
const store = readAcceptedActionStore(sessionManager);
|
|
46
|
+
if (store.pendingActions.some((record) => record.id === normalizedActionId)) return;
|
|
47
|
+
writeAcceptedActionStore(storePath, {
|
|
48
|
+
acceptedActionIds: store.acceptedActionIds,
|
|
49
|
+
pendingActions: [
|
|
50
|
+
...store.pendingActions.slice(-499),
|
|
51
|
+
{ id: normalizedActionId, pendingAtMs: Date.now() },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearPendingSessionAction(
|
|
57
|
+
sessionManager: SessionLockScopeSource,
|
|
58
|
+
actionId: string,
|
|
59
|
+
): void {
|
|
60
|
+
const normalizedActionId = actionId.trim();
|
|
61
|
+
if (!normalizedActionId) return;
|
|
62
|
+
const storePath = acceptedActionStorePath(sessionManager);
|
|
63
|
+
if (!storePath) return;
|
|
64
|
+
const store = readAcceptedActionStore(sessionManager);
|
|
65
|
+
if (!store.pendingActions.some((record) => record.id === normalizedActionId)) return;
|
|
66
|
+
writeAcceptedActionStore(storePath, {
|
|
67
|
+
acceptedActionIds: store.acceptedActionIds,
|
|
68
|
+
pendingActions: store.pendingActions.filter((record) => record.id !== normalizedActionId),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recordAcceptedSessionAction(
|
|
73
|
+
sessionManager: SessionLockScopeSource,
|
|
74
|
+
actionId: string,
|
|
75
|
+
): void {
|
|
76
|
+
const normalizedActionId = actionId.trim();
|
|
77
|
+
if (!normalizedActionId) return;
|
|
78
|
+
const storePath = acceptedActionStorePath(sessionManager);
|
|
79
|
+
if (!storePath) return;
|
|
80
|
+
const store = readAcceptedActionStore(sessionManager);
|
|
81
|
+
if (store.acceptedActionIds.includes(normalizedActionId)) return;
|
|
82
|
+
writeAcceptedActionStore(storePath, {
|
|
83
|
+
acceptedActionIds: [...store.acceptedActionIds.slice(-499), normalizedActionId],
|
|
84
|
+
pendingActions: store.pendingActions.filter((record) => record.id !== normalizedActionId),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readAcceptedActionStore(sessionManager: SessionLockScopeSource): AcceptedActionStore {
|
|
89
|
+
try {
|
|
90
|
+
const storePath = acceptedActionStorePath(sessionManager);
|
|
91
|
+
if (!storePath) return { acceptedActionIds: [], pendingActions: [] };
|
|
92
|
+
const parsed = JSON.parse(readFileSync(storePath, "utf8")) as {
|
|
93
|
+
acceptedActionIds?: unknown;
|
|
94
|
+
pendingActionIds?: unknown;
|
|
95
|
+
};
|
|
96
|
+
const pendingRecords = readPendingActionRecords(parsed.pendingActionIds);
|
|
97
|
+
const acceptedActionIds = Array.isArray(parsed.acceptedActionIds)
|
|
98
|
+
? parsed.acceptedActionIds.filter((id): id is string => typeof id === "string")
|
|
99
|
+
: [];
|
|
100
|
+
return {
|
|
101
|
+
acceptedActionIds,
|
|
102
|
+
pendingActions: pendingRecords.activeRecords,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return { acceptedActionIds: [], pendingActions: [] };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeAcceptedActionStore(storePath: string, store: AcceptedActionStore): void {
|
|
110
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
111
|
+
writeFileSync(
|
|
112
|
+
storePath,
|
|
113
|
+
JSON.stringify(
|
|
114
|
+
{
|
|
115
|
+
acceptedActionIds: store.acceptedActionIds,
|
|
116
|
+
pendingActionIds: store.pendingActions,
|
|
117
|
+
},
|
|
118
|
+
null,
|
|
119
|
+
2,
|
|
120
|
+
),
|
|
121
|
+
{ mode: 0o600 },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readPendingActionRecords(value: unknown): {
|
|
126
|
+
activeRecords: PendingActionRecord[];
|
|
127
|
+
} {
|
|
128
|
+
if (!Array.isArray(value)) return { activeRecords: [] };
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const activeRecords: PendingActionRecord[] = [];
|
|
131
|
+
for (const entry of value) {
|
|
132
|
+
const record = (() => {
|
|
133
|
+
if (typeof entry === "string") return null;
|
|
134
|
+
if (!entry || typeof entry !== "object") return null;
|
|
135
|
+
const pending = entry as { id?: unknown; pendingAtMs?: unknown };
|
|
136
|
+
if (typeof pending.id !== "string" || typeof pending.pendingAtMs !== "number") return null;
|
|
137
|
+
return { id: pending.id, pendingAtMs: pending.pendingAtMs };
|
|
138
|
+
})();
|
|
139
|
+
if (!record) continue;
|
|
140
|
+
if (now - record.pendingAtMs <= pendingSessionActionTtlMs) activeRecords.push(record);
|
|
141
|
+
}
|
|
142
|
+
return { activeRecords };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function acceptedActionStorePath(sessionManager: SessionLockScopeSource): string {
|
|
146
|
+
let scope: string;
|
|
147
|
+
try {
|
|
148
|
+
scope = writerLockScopeForSession(sessionManager);
|
|
149
|
+
} catch {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
return scope.endsWith(".jsonl")
|
|
153
|
+
? `${scope}.accepted-actions.json`
|
|
154
|
+
: join(scope, "accepted-actions.json");
|
|
155
|
+
}
|
|
@@ -16,11 +16,22 @@ export interface WriterLock {
|
|
|
16
16
|
processKind: ProcessKind;
|
|
17
17
|
acquiredAt: string;
|
|
18
18
|
lastHeartbeat: string;
|
|
19
|
+
protocolVersion?: number;
|
|
20
|
+
scope?: string;
|
|
21
|
+
ownerId?: string;
|
|
22
|
+
coordinatorEndpoint?: string;
|
|
23
|
+
coordinatorSecret?: string;
|
|
24
|
+
recoveryState?: "ambiguous" | "live";
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export interface AcquireOptions {
|
|
22
28
|
pid?: number;
|
|
29
|
+
ownerId?: string;
|
|
30
|
+
coordinatorEndpoint?: string;
|
|
31
|
+
coordinatorSecret?: string;
|
|
23
32
|
staleGraceMs?: number;
|
|
33
|
+
now?: () => Date;
|
|
34
|
+
isPidAlive?: (pid: number) => boolean;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export type AcquireResult =
|
|
@@ -33,6 +44,9 @@ export interface SessionLockScopeSource {
|
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
const DEFAULT_STALE_GRACE_MS = 15_000;
|
|
47
|
+
const WRITER_LOCK_PROTOCOL_VERSION = 1;
|
|
48
|
+
const PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
49
|
+
const PENDING_SESSION_ACTION_TTL_MS = 2 * 60 * 1000;
|
|
36
50
|
|
|
37
51
|
export async function acquireWriterLock(
|
|
38
52
|
scopePath: string,
|
|
@@ -40,21 +54,28 @@ export async function acquireWriterLock(
|
|
|
40
54
|
options: AcquireOptions = {},
|
|
41
55
|
): Promise<AcquireResult> {
|
|
42
56
|
mkdirSync(dirname(lockPath(scopePath)), { recursive: true });
|
|
43
|
-
const
|
|
57
|
+
const identity = lockIdentity(options);
|
|
44
58
|
const staleGraceMs = options.staleGraceMs ?? DEFAULT_STALE_GRACE_MS;
|
|
59
|
+
const pidAlive = options.isPidAlive ?? isPidAlive;
|
|
60
|
+
const now = options.now ?? (() => new Date());
|
|
45
61
|
|
|
46
|
-
const created = tryCreate(scopePath, processKind,
|
|
62
|
+
const created = tryCreate(scopePath, processKind, identity, now);
|
|
47
63
|
if (created) return { role: "writer", lock: created };
|
|
48
64
|
|
|
49
65
|
const existing = readWriterLock(scopePath);
|
|
50
|
-
if (existing
|
|
51
|
-
|
|
66
|
+
if (existing) {
|
|
67
|
+
const status = classifyLock(existing, staleGraceMs, pidAlive);
|
|
68
|
+
if (status !== "recoverable")
|
|
69
|
+
return { role: "follower", lock: withRecoveryState(existing, status) };
|
|
52
70
|
}
|
|
53
71
|
|
|
54
72
|
await sleep(staleGraceMs);
|
|
55
73
|
const afterGrace = readWriterLock(scopePath);
|
|
56
|
-
if (afterGrace
|
|
57
|
-
|
|
74
|
+
if (afterGrace) {
|
|
75
|
+
const status = classifyLock(afterGrace, staleGraceMs, pidAlive);
|
|
76
|
+
if (status !== "recoverable") {
|
|
77
|
+
return { role: "follower", lock: withRecoveryState(afterGrace, status) };
|
|
78
|
+
}
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
try {
|
|
@@ -63,7 +84,7 @@ export async function acquireWriterLock(
|
|
|
63
84
|
// Missing or concurrently removed is fine; the next create decides ownership.
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
const recovered = tryCreate(scopePath, processKind,
|
|
87
|
+
const recovered = tryCreate(scopePath, processKind, identity, now);
|
|
67
88
|
if (recovered) return { role: "writer", lock: recovered };
|
|
68
89
|
|
|
69
90
|
const current = readWriterLock(scopePath) ?? afterGrace ?? existing;
|
|
@@ -79,18 +100,36 @@ export function readWriterLock(scopePath: string): WriterLock | null {
|
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
export function refreshWriterLock(
|
|
103
|
+
export function refreshWriterLock(
|
|
104
|
+
scopePath: string,
|
|
105
|
+
holder: number | { pid?: number; ownerId?: string; now?: () => Date } = process.pid,
|
|
106
|
+
): void {
|
|
107
|
+
const identity =
|
|
108
|
+
typeof holder === "number"
|
|
109
|
+
? { pid: holder, ownerId: holder === process.pid ? defaultOwnerId(holder) : undefined }
|
|
110
|
+
: { pid: holder.pid ?? process.pid, ownerId: holder.ownerId, now: holder.now };
|
|
83
111
|
const lock = readWriterLock(scopePath);
|
|
84
|
-
if (!lock || lock
|
|
112
|
+
if (!lock || !isSameLockOwner(lock, identity)) return;
|
|
85
113
|
writeFileSync(
|
|
86
114
|
lockPath(scopePath),
|
|
87
|
-
JSON.stringify(
|
|
115
|
+
JSON.stringify(
|
|
116
|
+
{ ...lock, lastHeartbeat: (identity.now?.() ?? new Date()).toISOString() },
|
|
117
|
+
null,
|
|
118
|
+
2,
|
|
119
|
+
),
|
|
88
120
|
);
|
|
89
121
|
}
|
|
90
122
|
|
|
91
|
-
export function releaseWriterLock(
|
|
123
|
+
export function releaseWriterLock(
|
|
124
|
+
scopePath: string,
|
|
125
|
+
holder: number | { pid?: number; ownerId?: string } = process.pid,
|
|
126
|
+
): void {
|
|
127
|
+
const identity =
|
|
128
|
+
typeof holder === "number"
|
|
129
|
+
? { pid: holder, ownerId: holder === process.pid ? defaultOwnerId(holder) : undefined }
|
|
130
|
+
: { pid: holder.pid ?? process.pid, ownerId: holder.ownerId };
|
|
92
131
|
const lock = readWriterLock(scopePath);
|
|
93
|
-
if (!lock || lock
|
|
132
|
+
if (!lock || !isSameLockOwner(lock, identity)) return;
|
|
94
133
|
try {
|
|
95
134
|
unlinkSync(lockPath(scopePath));
|
|
96
135
|
} catch {
|
|
@@ -106,11 +145,179 @@ export function writerLockScopeForSession(sessionManager: SessionLockScopeSource
|
|
|
106
145
|
return sessionManager.getSessionFile() ?? sessionManager.getSessionDir();
|
|
107
146
|
}
|
|
108
147
|
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
148
|
+
export function migrateWriterLockScope(
|
|
149
|
+
fromScopePath: string,
|
|
150
|
+
toScopePath: string,
|
|
151
|
+
holder: { pid?: number; ownerId?: string } = {},
|
|
152
|
+
): boolean {
|
|
153
|
+
if (fromScopePath === toScopePath) return true;
|
|
154
|
+
const pid = holder.pid ?? process.pid;
|
|
155
|
+
const identity = {
|
|
156
|
+
pid,
|
|
157
|
+
ownerId: holder.ownerId ?? (pid === process.pid ? defaultOwnerId(pid) : undefined),
|
|
158
|
+
};
|
|
159
|
+
const lock = readWriterLock(fromScopePath);
|
|
160
|
+
if (!lock || !isSameLockOwner(lock, identity)) return false;
|
|
161
|
+
|
|
162
|
+
mkdirSync(dirname(lockPath(toScopePath)), { recursive: true });
|
|
163
|
+
const nextLock = { ...lock, scope: toScopePath };
|
|
164
|
+
let fd: number | undefined;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(lockPath(toScopePath), "wx", 0o600);
|
|
167
|
+
writeFileSync(fd, JSON.stringify(nextLock, null, 2));
|
|
168
|
+
} catch {
|
|
169
|
+
const destinationLock = readWriterLock(toScopePath);
|
|
170
|
+
if (destinationLock && isSameLockOwner(destinationLock, identity)) {
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(lockPath(fromScopePath));
|
|
173
|
+
} catch {
|
|
174
|
+
// Best effort; destination lock is already authoritative for this owner.
|
|
175
|
+
}
|
|
176
|
+
migrateAcceptedActionStore(fromScopePath, toScopePath);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (destinationLock && !isSameLockOwner(destinationLock, identity)) {
|
|
180
|
+
try {
|
|
181
|
+
unlinkSync(lockPath(fromScopePath));
|
|
182
|
+
} catch {
|
|
183
|
+
// The canonical lock is already owned elsewhere; do not keep refreshing fallback.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
} finally {
|
|
188
|
+
if (fd !== undefined) closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(lockPath(fromScopePath));
|
|
193
|
+
} catch {
|
|
194
|
+
// Best effort; destination lock is now authoritative for the canonical scope.
|
|
195
|
+
}
|
|
196
|
+
migrateAcceptedActionStore(fromScopePath, toScopePath);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function migrateAcceptedActionStore(fromScopePath: string, toScopePath: string): void {
|
|
201
|
+
const fromPath = acceptedActionStorePath(fromScopePath);
|
|
202
|
+
const toPath = acceptedActionStorePath(toScopePath);
|
|
203
|
+
let fromActions: AcceptedActionStore;
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(readFileSync(fromPath, "utf8")) as { acceptedActionIds?: unknown };
|
|
206
|
+
fromActions = parseAcceptedActionStore(parsed);
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
let toActions: AcceptedActionStore = {
|
|
211
|
+
acceptedActionIds: [],
|
|
212
|
+
pendingActions: [],
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(readFileSync(toPath, "utf8")) as { acceptedActionIds?: unknown };
|
|
216
|
+
toActions = parseAcceptedActionStore(parsed);
|
|
217
|
+
} catch {
|
|
218
|
+
// Destination store does not exist yet.
|
|
219
|
+
}
|
|
220
|
+
const acceptedActionIds = [
|
|
221
|
+
...new Set([...toActions.acceptedActionIds, ...fromActions.acceptedActionIds]),
|
|
222
|
+
].slice(-500);
|
|
223
|
+
const pendingActionsById = new Map<string, PendingActionRecord>();
|
|
224
|
+
for (const action of [...toActions.pendingActions, ...fromActions.pendingActions]) {
|
|
225
|
+
if (!acceptedActionIds.includes(action.id)) pendingActionsById.set(action.id, action);
|
|
226
|
+
}
|
|
227
|
+
const pendingActions = [...pendingActionsById.values()].slice(-500);
|
|
228
|
+
mkdirSync(dirname(toPath), { recursive: true });
|
|
229
|
+
writeFileSync(
|
|
230
|
+
toPath,
|
|
231
|
+
JSON.stringify(
|
|
232
|
+
{
|
|
233
|
+
acceptedActionIds,
|
|
234
|
+
pendingActionIds: pendingActions.map((action) => ({
|
|
235
|
+
id: action.id,
|
|
236
|
+
pendingAtMs: action.pendingAtMs,
|
|
237
|
+
})),
|
|
238
|
+
},
|
|
239
|
+
null,
|
|
240
|
+
2,
|
|
241
|
+
),
|
|
242
|
+
{
|
|
243
|
+
mode: 0o600,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
112
246
|
try {
|
|
113
|
-
|
|
247
|
+
unlinkSync(fromPath);
|
|
248
|
+
} catch {
|
|
249
|
+
// Best effort; destination store now contains the merged accepted action ids.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface PendingActionRecord {
|
|
254
|
+
id: string;
|
|
255
|
+
pendingAtMs: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface AcceptedActionStore {
|
|
259
|
+
acceptedActionIds: string[];
|
|
260
|
+
pendingActions: PendingActionRecord[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseAcceptedActionStore(parsed: {
|
|
264
|
+
acceptedActionIds?: unknown;
|
|
265
|
+
pendingActionIds?: unknown;
|
|
266
|
+
}): AcceptedActionStore {
|
|
267
|
+
const acceptedActionIds = Array.isArray(parsed.acceptedActionIds)
|
|
268
|
+
? parsed.acceptedActionIds.filter((id): id is string => typeof id === "string")
|
|
269
|
+
: [];
|
|
270
|
+
const pending = parsePendingActionRecords(parsed.pendingActionIds);
|
|
271
|
+
return {
|
|
272
|
+
acceptedActionIds,
|
|
273
|
+
pendingActions: pending.activeRecords,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parsePendingActionRecords(value: unknown): {
|
|
278
|
+
activeRecords: PendingActionRecord[];
|
|
279
|
+
} {
|
|
280
|
+
if (!Array.isArray(value)) return { activeRecords: [] };
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const activeRecords: PendingActionRecord[] = [];
|
|
283
|
+
for (const entry of value) {
|
|
284
|
+
const record = (() => {
|
|
285
|
+
if (!entry || typeof entry !== "object") return null;
|
|
286
|
+
const pending = entry as { id?: unknown; pendingAtMs?: unknown };
|
|
287
|
+
if (typeof pending.id !== "string" || typeof pending.pendingAtMs !== "number") return null;
|
|
288
|
+
return { id: pending.id, pendingAtMs: pending.pendingAtMs };
|
|
289
|
+
})();
|
|
290
|
+
if (!record) continue;
|
|
291
|
+
if (now - record.pendingAtMs <= PENDING_SESSION_ACTION_TTL_MS) activeRecords.push(record);
|
|
292
|
+
}
|
|
293
|
+
return { activeRecords };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function tryCreate(
|
|
297
|
+
scopePath: string,
|
|
298
|
+
processKind: ProcessKind,
|
|
299
|
+
identity: {
|
|
300
|
+
pid: number;
|
|
301
|
+
ownerId?: string;
|
|
302
|
+
coordinatorEndpoint?: string;
|
|
303
|
+
coordinatorSecret?: string;
|
|
304
|
+
},
|
|
305
|
+
nowFn: () => Date,
|
|
306
|
+
): WriterLock | null {
|
|
307
|
+
const now = nowFn().toISOString();
|
|
308
|
+
const lock: WriterLock = {
|
|
309
|
+
pid: identity.pid,
|
|
310
|
+
processKind,
|
|
311
|
+
acquiredAt: now,
|
|
312
|
+
lastHeartbeat: now,
|
|
313
|
+
protocolVersion: WRITER_LOCK_PROTOCOL_VERSION,
|
|
314
|
+
scope: scopePath,
|
|
315
|
+
...(identity.ownerId ? { ownerId: identity.ownerId } : {}),
|
|
316
|
+
...(identity.coordinatorEndpoint ? { coordinatorEndpoint: identity.coordinatorEndpoint } : {}),
|
|
317
|
+
...(identity.coordinatorSecret ? { coordinatorSecret: identity.coordinatorSecret } : {}),
|
|
318
|
+
};
|
|
319
|
+
try {
|
|
320
|
+
const fd = openSync(lockPath(scopePath), "wx", 0o600);
|
|
114
321
|
try {
|
|
115
322
|
writeFileSync(fd, JSON.stringify(lock, null, 2));
|
|
116
323
|
} finally {
|
|
@@ -131,17 +338,80 @@ function isPidAlive(pid: number): boolean {
|
|
|
131
338
|
}
|
|
132
339
|
}
|
|
133
340
|
|
|
134
|
-
|
|
341
|
+
// Unlike isPidAlive, EPERM counts as alive: the process exists but belongs to
|
|
342
|
+
// another user, so its coordinator may still be serving the session.
|
|
343
|
+
export function isCoordinatorOwnerAlive(pid: number): boolean {
|
|
344
|
+
try {
|
|
345
|
+
process.kill(pid, 0);
|
|
346
|
+
return true;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function shouldBlockFailedCoordinatorAction(
|
|
353
|
+
sessionManager: SessionLockScopeSource,
|
|
354
|
+
): boolean {
|
|
355
|
+
const lock = readWriterLock(writerLockScopeForSession(sessionManager));
|
|
356
|
+
if (!lock || lock.pid === process.pid) return false;
|
|
357
|
+
return isCoordinatorOwnerAlive(lock.pid);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function classifyLock(
|
|
361
|
+
lock: WriterLock,
|
|
362
|
+
staleGraceMs: number,
|
|
363
|
+
pidAlive: (pid: number) => boolean,
|
|
364
|
+
): "current" | "ambiguous" | "recoverable" {
|
|
135
365
|
const heartbeat = Date.parse(lock.lastHeartbeat);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
366
|
+
const hasFreshHeartbeat = Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs;
|
|
367
|
+
if (!pidAlive(lock.pid)) return "recoverable";
|
|
368
|
+
if (hasFreshHeartbeat) return "current";
|
|
369
|
+
if (lock.pid === process.pid && lock.ownerId === defaultOwnerId(process.pid)) return "current";
|
|
370
|
+
return "ambiguous";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function withRecoveryState(lock: WriterLock, status: "current" | "ambiguous"): WriterLock {
|
|
374
|
+
if (status === "current") return { ...lock, recoveryState: "live" };
|
|
375
|
+
return { ...lock, recoveryState: "ambiguous" };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isSameLockOwner(lock: WriterLock, identity: { pid: number; ownerId?: string }): boolean {
|
|
379
|
+
if (lock.pid !== identity.pid) return false;
|
|
380
|
+
if (lock.ownerId) return lock.ownerId === identity.ownerId;
|
|
381
|
+
return !identity.ownerId;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function lockIdentity(options: AcquireOptions): {
|
|
385
|
+
pid: number;
|
|
386
|
+
ownerId?: string;
|
|
387
|
+
coordinatorEndpoint?: string;
|
|
388
|
+
coordinatorSecret?: string;
|
|
389
|
+
} {
|
|
390
|
+
const pid = options.pid ?? process.pid;
|
|
391
|
+
const ownerId =
|
|
392
|
+
options.ownerId === undefined && pid === process.pid ? defaultOwnerId(pid) : options.ownerId;
|
|
393
|
+
return {
|
|
394
|
+
pid,
|
|
395
|
+
...(ownerId ? { ownerId } : {}),
|
|
396
|
+
...(options.coordinatorEndpoint ? { coordinatorEndpoint: options.coordinatorEndpoint } : {}),
|
|
397
|
+
...(options.coordinatorSecret ? { coordinatorSecret: options.coordinatorSecret } : {}),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function defaultOwnerId(pid: number): string {
|
|
402
|
+
return `${pid}:${process.ppid}:${PROCESS_STARTED_AT}`;
|
|
139
403
|
}
|
|
140
404
|
|
|
141
405
|
function lockPath(scopePath: string): string {
|
|
142
406
|
return isFileScope(scopePath) ? `${scopePath}.writer.lock` : join(scopePath, "writer.lock");
|
|
143
407
|
}
|
|
144
408
|
|
|
409
|
+
function acceptedActionStorePath(scopePath: string): string {
|
|
410
|
+
return isFileScope(scopePath)
|
|
411
|
+
? `${scopePath}.accepted-actions.json`
|
|
412
|
+
: join(scopePath, "accepted-actions.json");
|
|
413
|
+
}
|
|
414
|
+
|
|
145
415
|
function isFileScope(scopePath: string): boolean {
|
|
146
416
|
if (scopePath.endsWith(".jsonl")) return true;
|
|
147
417
|
try {
|
package/src/pi/setup.ts
CHANGED
|
@@ -211,10 +211,10 @@ async function runLoginDialog(ctx: ExtensionContext, providerId: string): Promis
|
|
|
211
211
|
async function runApiKeySetup(ctx: ExtensionContext, provider: ApiKeyProviderId): Promise<boolean> {
|
|
212
212
|
const label = API_KEY_PROVIDER_LABELS[provider];
|
|
213
213
|
ctx.ui.notify(
|
|
214
|
-
`OpenCandle stores your ${label} API key locally
|
|
214
|
+
`OpenCandle stores your ${label} API key locally. After saving it, OpenCandle will select a fast default model when one is available.`,
|
|
215
215
|
"info",
|
|
216
216
|
);
|
|
217
|
-
const key = await ctx.ui.input(`Paste your ${label} API key
|
|
217
|
+
const key = await ctx.ui.input(`Paste your ${label} API key`, "API key");
|
|
218
218
|
const trimmed = key?.trim();
|
|
219
219
|
if (!trimmed) {
|
|
220
220
|
ctx.ui.notify("No API key entered.", "warning");
|
|
@@ -323,11 +323,11 @@ async function runLlmSetup(
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
const choice = await ctx.ui.select(
|
|
326
|
-
"Welcome to OpenCandle —
|
|
327
|
-
["Sign in", "Paste API key", "Exit setup"],
|
|
326
|
+
"Welcome to OpenCandle — connect one AI model to start chatting",
|
|
327
|
+
["Sign in with browser", "Paste API key", "Exit setup"],
|
|
328
328
|
);
|
|
329
329
|
|
|
330
|
-
if (choice !== "Sign in" && choice !== "Paste API key") {
|
|
330
|
+
if (choice !== "Sign in with browser" && choice !== "Paste API key") {
|
|
331
331
|
if (mode === "startup") {
|
|
332
332
|
ctx.ui.notify("OpenCandle needs an AI model before chat can start.", "warning");
|
|
333
333
|
ctx.shutdown();
|
|
@@ -336,7 +336,7 @@ async function runLlmSetup(
|
|
|
336
336
|
return "cancelled";
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
if (choice === "Sign in") {
|
|
339
|
+
if (choice === "Sign in with browser") {
|
|
340
340
|
const providerChoice = await selectProviderForLogin(ctx);
|
|
341
341
|
if (!providerChoice) {
|
|
342
342
|
continue;
|