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.
Files changed (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. 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 pid = options.pid ?? process.pid;
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, pid);
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 && isLockCurrent(existing, staleGraceMs)) {
51
- return { role: "follower", lock: existing };
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 && isLockCurrent(afterGrace, staleGraceMs)) {
57
- return { role: "follower", lock: afterGrace };
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, pid);
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(scopePath: string, pid = process.pid): void {
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.pid !== pid) return;
112
+ if (!lock || !isSameLockOwner(lock, identity)) return;
85
113
  writeFileSync(
86
114
  lockPath(scopePath),
87
- JSON.stringify({ ...lock, lastHeartbeat: new Date().toISOString() }, null, 2),
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(scopePath: string, pid = process.pid): void {
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.pid !== pid) return;
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 tryCreate(scopePath: string, processKind: ProcessKind, pid: number): WriterLock | null {
110
- const now = new Date().toISOString();
111
- const lock: WriterLock = { pid, processKind, acquiredAt: now, lastHeartbeat: now };
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
- const fd = openSync(lockPath(scopePath), "wx");
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
- function isLockCurrent(lock: WriterLock, staleGraceMs: number): boolean {
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
- return (
137
- isPidAlive(lock.pid) && Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs
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 and only sends it to ${label}.`,
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 for OpenCandle`, "sk-...");
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 — sign in or paste an API key to start chatting",
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;