vigthoria-cli 1.13.20 → 1.13.23

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 (187) hide show
  1. package/completions/_vigthoria +49 -0
  2. package/completions/vigthoria.bash +6 -0
  3. package/completions/vigthoria.fish +41 -0
  4. package/dist/commands/auth.d.ts +1 -0
  5. package/dist/commands/auth.js +63 -128
  6. package/dist/commands/background.d.ts +0 -2
  7. package/dist/commands/background.js +26 -34
  8. package/dist/commands/bridge.js +45 -41
  9. package/dist/commands/cancel.d.ts +1 -0
  10. package/dist/commands/cancel.js +22 -16
  11. package/dist/commands/chat.d.ts +3 -42
  12. package/dist/commands/chat.js +378 -1123
  13. package/dist/commands/coding-registration.d.ts +4 -0
  14. package/dist/commands/coding-registration.js +24 -0
  15. package/dist/commands/config.js +23 -29
  16. package/dist/commands/conversation-registration.d.ts +4 -0
  17. package/dist/commands/conversation-registration.js +122 -0
  18. package/dist/commands/deploy.d.ts +8 -2
  19. package/dist/commands/deploy.js +92 -90
  20. package/dist/commands/device.d.ts +1 -2
  21. package/dist/commands/device.js +37 -21
  22. package/dist/commands/doctor-registration.d.ts +4 -0
  23. package/dist/commands/doctor-registration.js +207 -0
  24. package/dist/commands/edit.js +22 -33
  25. package/dist/commands/explain.js +7 -10
  26. package/dist/commands/fork.d.ts +1 -2
  27. package/dist/commands/fork.js +72 -22
  28. package/dist/commands/game.d.ts +1 -1
  29. package/dist/commands/game.js +17 -6
  30. package/dist/commands/generate.js +7 -9
  31. package/dist/commands/history.js +11 -7
  32. package/dist/commands/hub.d.ts +2 -2
  33. package/dist/commands/hub.js +34 -35
  34. package/dist/commands/legion.d.ts +0 -1
  35. package/dist/commands/legion.js +62 -79
  36. package/dist/commands/music.d.ts +1 -2
  37. package/dist/commands/music.js +62 -27
  38. package/dist/commands/platform-registration.d.ts +4 -0
  39. package/dist/commands/platform-registration.js +45 -0
  40. package/dist/commands/preview.d.ts +0 -1
  41. package/dist/commands/preview.js +82 -35
  42. package/dist/commands/product-run-registration.d.ts +4 -0
  43. package/dist/commands/product-run-registration.js +490 -0
  44. package/dist/commands/replay.d.ts +1 -2
  45. package/dist/commands/replay.js +15 -11
  46. package/dist/commands/repo.d.ts +14 -7
  47. package/dist/commands/repo.js +244 -198
  48. package/dist/commands/review.js +9 -12
  49. package/dist/commands/security.d.ts +1 -2
  50. package/dist/commands/security.js +12 -6
  51. package/dist/commands/update-registration.d.ts +3 -0
  52. package/dist/commands/update-registration.js +368 -0
  53. package/dist/commands/v4-registration.d.ts +4 -0
  54. package/dist/commands/v4-registration.js +24 -0
  55. package/dist/commands/v4.d.ts +1 -1
  56. package/dist/commands/v4.js +46 -8
  57. package/dist/commands/wallet.d.ts +1 -3
  58. package/dist/commands/wallet.js +18 -45
  59. package/dist/commands/workflow.d.ts +2 -0
  60. package/dist/commands/workflow.js +52 -39
  61. package/dist/index.js +124 -1589
  62. package/dist/utils/agent-stream-state.d.ts +27 -0
  63. package/dist/utils/agent-stream-state.js +93 -0
  64. package/dist/utils/agentRoute.d.ts +2 -1
  65. package/dist/utils/agentRoute.js +22 -9
  66. package/dist/utils/agentRunOutcome.d.ts +16 -0
  67. package/dist/utils/agentRunOutcome.js +37 -2
  68. package/dist/utils/api-client-factory.d.ts +8 -0
  69. package/dist/utils/api-client-factory.js +8 -0
  70. package/dist/utils/api.d.ts +44 -266
  71. package/dist/utils/api.js +647 -3180
  72. package/dist/utils/auth-session.d.ts +41 -0
  73. package/dist/utils/auth-session.js +224 -0
  74. package/dist/utils/brain-hub-client.d.ts +0 -4
  75. package/dist/utils/brain-hub-client.js +6 -3
  76. package/dist/utils/bridge-client.js +2 -0
  77. package/dist/utils/capability-health-service.d.ts +48 -0
  78. package/dist/utils/capability-health-service.js +64 -0
  79. package/dist/utils/chat-prompt-policy.d.ts +18 -0
  80. package/dist/utils/chat-prompt-policy.js +61 -0
  81. package/dist/utils/chat-tool-call-parser.d.ts +11 -0
  82. package/dist/utils/chat-tool-call-parser.js +71 -0
  83. package/dist/utils/cli-state.d.ts +22 -4
  84. package/dist/utils/cli-state.js +105 -47
  85. package/dist/utils/code-operations-service.d.ts +104 -0
  86. package/dist/utils/code-operations-service.js +837 -0
  87. package/dist/utils/codebase-indexer.d.ts +1 -0
  88. package/dist/utils/codebase-indexer.js +28 -8
  89. package/dist/utils/command-contract.d.ts +61 -0
  90. package/dist/utils/command-contract.js +149 -0
  91. package/dist/utils/command-policy.d.ts +10 -0
  92. package/dist/utils/command-policy.js +54 -0
  93. package/dist/utils/config.d.ts +13 -5
  94. package/dist/utils/config.js +220 -94
  95. package/dist/utils/context-ranker.js +11 -2
  96. package/dist/utils/context-state.d.ts +39 -0
  97. package/dist/utils/context-state.js +52 -0
  98. package/dist/utils/deckEvents.d.ts +0 -5
  99. package/dist/utils/deckEvents.js +3 -2
  100. package/dist/utils/desktop-bridge-client.js +2 -0
  101. package/dist/utils/direct-output-policy.d.ts +3 -0
  102. package/dist/utils/direct-output-policy.js +76 -0
  103. package/dist/utils/durable-json.d.ts +57 -0
  104. package/dist/utils/durable-json.js +319 -0
  105. package/dist/utils/fastAgentRouter.d.ts +5 -6
  106. package/dist/utils/fastAgentRouter.js +17 -25
  107. package/dist/utils/files.js +21 -13
  108. package/dist/utils/frontend-preview-service.d.ts +76 -0
  109. package/dist/utils/frontend-preview-service.js +482 -0
  110. package/dist/utils/goaEvents.d.ts +0 -4
  111. package/dist/utils/goaEvents.js +5 -2
  112. package/dist/utils/logger.d.ts +2 -1
  113. package/dist/utils/logger.js +27 -20
  114. package/dist/utils/mcp-context-client.d.ts +24 -0
  115. package/dist/utils/mcp-context-client.js +58 -0
  116. package/dist/utils/model-governance.d.ts +18 -0
  117. package/dist/utils/model-governance.js +75 -0
  118. package/dist/utils/model-transport-service.d.ts +44 -0
  119. package/dist/utils/model-transport-service.js +111 -0
  120. package/dist/utils/mutation-journal.d.ts +42 -0
  121. package/dist/utils/mutation-journal.js +387 -0
  122. package/dist/utils/network-policy.d.ts +24 -0
  123. package/dist/utils/network-policy.js +202 -0
  124. package/dist/utils/operator-client.d.ts +45 -0
  125. package/dist/utils/operator-client.js +159 -0
  126. package/dist/utils/post-write-validator.js +7 -9
  127. package/dist/utils/preview-screenshot-adapter.d.ts +15 -0
  128. package/dist/utils/preview-screenshot-adapter.js +51 -0
  129. package/dist/utils/process-policy.d.ts +23 -0
  130. package/dist/utils/process-policy.js +284 -0
  131. package/dist/utils/project-memory.d.ts +13 -3
  132. package/dist/utils/project-memory.js +70 -17
  133. package/dist/utils/prompt-workspace-resolver.d.ts +14 -0
  134. package/dist/utils/prompt-workspace-resolver.js +64 -0
  135. package/dist/utils/release-install.d.ts +11 -0
  136. package/dist/utils/release-install.js +155 -0
  137. package/dist/utils/release-policy.d.ts +64 -0
  138. package/dist/utils/release-policy.js +293 -0
  139. package/dist/utils/requestIntent.js +22 -3
  140. package/dist/utils/runtime-capability.d.ts +8 -0
  141. package/dist/utils/runtime-capability.js +16 -0
  142. package/dist/utils/runtime-temp.d.ts +76 -0
  143. package/dist/utils/runtime-temp.js +492 -0
  144. package/dist/utils/secret-policy.d.ts +20 -0
  145. package/dist/utils/secret-policy.js +160 -0
  146. package/dist/utils/session.d.ts +17 -3
  147. package/dist/utils/session.js +354 -110
  148. package/dist/utils/tool-approval-service.d.ts +17 -0
  149. package/dist/utils/tool-approval-service.js +78 -0
  150. package/dist/utils/tool-capability-providers.d.ts +57 -0
  151. package/dist/utils/tool-capability-providers.js +57 -0
  152. package/dist/utils/tools.d.ts +2 -23
  153. package/dist/utils/tools.js +226 -512
  154. package/dist/utils/update-policy.d.ts +18 -0
  155. package/dist/utils/update-policy.js +137 -0
  156. package/dist/utils/v3-agent-client.d.ts +40 -0
  157. package/dist/utils/v3-agent-client.js +165 -0
  158. package/dist/utils/v3-workspace-service.d.ts +28 -0
  159. package/dist/utils/v3-workspace-service.js +279 -0
  160. package/dist/utils/vigflow-client.d.ts +95 -0
  161. package/dist/utils/vigflow-client.js +105 -0
  162. package/dist/utils/workspace-boundary.d.ts +45 -0
  163. package/dist/utils/workspace-boundary.js +377 -0
  164. package/dist/utils/workspace-brain-service.d.ts +2 -0
  165. package/dist/utils/workspace-brain-service.js +11 -1
  166. package/dist/utils/workspace-cache.js +37 -15
  167. package/dist/utils/workspace-stream.js +35 -10
  168. package/install.ps1 +115 -142
  169. package/install.sh +112 -81
  170. package/package.json +43 -9
  171. package/release-policy.json +43 -0
  172. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  173. package/scripts/release/generate-completions.mjs +24 -0
  174. package/scripts/release/install-release.mjs +10 -0
  175. package/scripts/release/publish-cli-release.mjs +116 -0
  176. package/scripts/release/publish-cli-release.sh +2 -70
  177. package/scripts/release/resolve-release-manifest.mjs +26 -0
  178. package/scripts/release/test-balanced-model-live.sh +45 -0
  179. package/scripts/release/validate-live-service-gates.sh +93 -0
  180. package/scripts/release/validate-no-go-gates.sh +145 -174
  181. package/scripts/release/verify-runtime-consistency.mjs +4 -1
  182. package/dist/commands/index.d.ts +0 -12
  183. package/dist/commands/index.js +0 -194
  184. package/dist/commands/update.d.ts +0 -9
  185. package/dist/commands/update.js +0 -301
  186. package/dist/utils/templateInstantPath.d.ts +0 -18
  187. package/dist/utils/templateInstantPath.js +0 -228
@@ -5,28 +5,11 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
- import { atomicWriteJson, secureFileMode } from './cli-state.js';
9
- function createSessionLoadError(message, details) {
10
- return {
11
- code: 'SESSION_LOAD_FAILED',
12
- message,
13
- details,
14
- isCritical: true,
15
- };
16
- }
17
- function normalizeAuthState(raw) {
18
- if (!raw || typeof raw !== 'object') {
19
- return { token: null, expiresAt: null, isValid: false };
20
- }
21
- const source = raw.auth && typeof raw.auth === 'object' ? raw.auth : raw;
22
- const rawToken = source.token ?? source.accessToken ?? source.jwt ?? null;
23
- const rawExpiresAt = source.expiresAt ?? null;
24
- const token = typeof rawToken === 'string' && rawToken.trim().length > 0 ? rawToken.trim() : null;
25
- const expiresAt = typeof rawExpiresAt === 'number' && Number.isFinite(rawExpiresAt) ? rawExpiresAt : null;
26
- const session = { token, expiresAt, isValid: false };
27
- session.isValid = validateSession(session, { silent: true });
28
- return session;
29
- }
8
+ import { createHash, randomUUID } from 'crypto';
9
+ import { secureFileMode } from './cli-state.js';
10
+ import { redactSensitiveText } from './secret-policy.js';
11
+ import { Config } from './config.js';
12
+ import { DurableJsonStore } from './durable-json.js';
30
13
  /**
31
14
  * Validate persisted authentication state without assuming any field is present.
32
15
  *
@@ -68,27 +51,19 @@ export function validateSession(session, options = {}) {
68
51
  * exception. Callers that need a hard failure can inspect ``isValid``.
69
52
  */
70
53
  export async function loadSession() {
71
- const configDir = path.join(os.homedir(), '.vigthoria');
72
- const candidateFiles = [
73
- path.join(configDir, 'auth.json'),
74
- path.join(configDir, 'session.json'),
75
- path.join(configDir, 'config.json'),
76
- ];
77
- const sessionFile = candidateFiles.find((filePath) => {
78
- try {
79
- return fs.existsSync(filePath);
80
- }
81
- catch {
82
- return false;
83
- }
84
- });
85
- if (!sessionFile) {
86
- return { token: null, expiresAt: null, isValid: false };
87
- }
88
54
  try {
89
- const content = fs.readFileSync(sessionFile, 'utf-8');
90
- const parsed = JSON.parse(content);
91
- return normalizeAuthState(parsed);
55
+ const token = new Config().get('authToken');
56
+ if (!token)
57
+ return { token: null, expiresAt: null, isValid: false };
58
+ const segments = token.split('.');
59
+ if (segments.length !== 3) {
60
+ // Opaque credentials were server-accepted before persistence. Their
61
+ // expiry cannot be inferred locally and is revalidated at the gateway.
62
+ return { token, expiresAt: null, isValid: true };
63
+ }
64
+ const payload = JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8'));
65
+ const expiresAt = typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
66
+ return { token, expiresAt, isValid: expiresAt !== null && expiresAt > Date.now() };
92
67
  }
93
68
  catch (error) {
94
69
  if (process.env.VIGTHORIA_DEBUG === '1') {
@@ -97,8 +72,25 @@ export async function loadSession() {
97
72
  return { token: null, expiresAt: null, isValid: false };
98
73
  }
99
74
  }
75
+ const AGENT_STATUS_TRANSITIONS = {
76
+ submitted: new Set(['running', 'completed', 'failed', 'cancelled']),
77
+ retry: new Set(['running', 'completed', 'failed', 'cancelled']),
78
+ continue: new Set(['running', 'completed', 'failed', 'cancelled']),
79
+ running: new Set(['completed', 'failed', 'cancelled']),
80
+ completed: new Set(['retry', 'continue']),
81
+ failed: new Set(['retry', 'continue', 'cancelled']),
82
+ cancelled: new Set(['retry', 'continue']),
83
+ };
84
+ export function assertAgentStatusTransition(from, to) {
85
+ if (from === to)
86
+ return;
87
+ if (!AGENT_STATUS_TRANSITIONS[from]?.has(to)) {
88
+ throw new Error(`Invalid agent execution transition: ${from} -> ${to}`);
89
+ }
90
+ }
100
91
  export class SessionManager {
101
92
  sessionsDir;
93
+ indexStore;
102
94
  compactThreshold = 40;
103
95
  retainRecentMessages = 18;
104
96
  /** Keep at most this many session files on disk per CLI install. */
@@ -107,34 +99,136 @@ export class SessionManager {
107
99
  retentionWindowMs = 90 * 24 * 60 * 60 * 1000;
108
100
  /** Avoid running prune on every save — at most once per CLI process. */
109
101
  prunedThisProcess = false;
110
- constructor() {
111
- this.sessionsDir = path.join(os.homedir(), '.vigthoria', 'sessions');
102
+ constructor(stateRoot) {
103
+ this.sessionsDir = path.join(stateRoot || os.homedir(), '.vigthoria', 'sessions');
112
104
  this.ensureDir();
105
+ this.indexStore = new DurableJsonStore({
106
+ filePath: path.join(this.sessionsDir, 'index.json'),
107
+ schemaVersion: 1,
108
+ defaultValue: () => ({ sessions: {} }),
109
+ validate: (value) => Boolean(value && typeof value === 'object'
110
+ && value.sessions
111
+ && typeof value.sessions === 'object'),
112
+ migrate: (value) => value && typeof value === 'object' && value.sessions
113
+ ? value
114
+ : { sessions: {} },
115
+ });
113
116
  }
114
- ensureDir() {
117
+ workspaceSessionsDir(project) {
118
+ const candidate = String(project || '').trim();
119
+ if (!candidate || !path.isAbsolute(candidate))
120
+ return null;
115
121
  try {
116
- if (!fs.existsSync(this.sessionsDir)) {
117
- fs.mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
118
- }
119
- if (process.platform !== 'win32') {
120
- try {
121
- fs.chmodSync(this.sessionsDir, 0o700);
122
- }
123
- catch { /* best-effort */ }
122
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory())
123
+ return null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ try {
129
+ const realWorkspace = fs.realpathSync(candidate);
130
+ const sessionsCandidate = path.join(realWorkspace, '.vigthoria', 'sessions');
131
+ const vigthoriaCandidate = path.dirname(sessionsCandidate);
132
+ if (fs.existsSync(vigthoriaCandidate)) {
133
+ const realVigthoria = fs.realpathSync(vigthoriaCandidate);
134
+ if (realVigthoria !== path.join(realWorkspace, '.vigthoria'))
135
+ return null;
124
136
  }
137
+ return sessionsCandidate;
125
138
  }
126
- catch (error) {
127
- if (error?.code === 'EACCES' || error?.code === 'EPERM') {
128
- this.sessionsDir = path.join(os.tmpdir(), 'vigthoria-sessions');
129
- try {
130
- if (!fs.existsSync(this.sessionsDir)) {
131
- fs.mkdirSync(this.sessionsDir, { recursive: true });
132
- }
133
- }
134
- catch {
135
- // If even tmpdir is unwritable we'll fail at write time with a useful error.
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ sessionStore(filePath, fallback) {
144
+ return new DurableJsonStore({
145
+ filePath,
146
+ schemaVersion: 3,
147
+ defaultValue: () => fallback,
148
+ validate: (value) => Boolean(value && typeof value === 'object'
149
+ && typeof value.id === 'string'
150
+ && typeof value.project === 'string'
151
+ && Array.isArray(value.messages)),
152
+ migrate: (value) => {
153
+ if (!value || typeof value !== 'object')
154
+ throw new Error('Session is not an object.');
155
+ const legacy = value;
156
+ if (!legacy.id || !legacy.project || !Array.isArray(legacy.messages))
157
+ throw new Error('Session identity is incomplete.');
158
+ if (legacy.activeAgentRun) {
159
+ const allowed = new Set(['submitted', 'retry', 'continue', 'running', 'completed', 'failed', 'cancelled']);
160
+ if (!allowed.has(legacy.activeAgentRun.status))
161
+ legacy.activeAgentRun.status = 'failed';
136
162
  }
163
+ return this.ensureMessageIds(legacy);
164
+ },
165
+ });
166
+ }
167
+ ensureMessageIds(session) {
168
+ const hadIds = Array.isArray(session.messageIds);
169
+ const ids = hadIds ? [...session.messageIds] : [];
170
+ if (ids.length > session.messages.length)
171
+ ids.length = session.messages.length;
172
+ for (let index = ids.length; index < session.messages.length; index += 1) {
173
+ ids.push(hadIds
174
+ ? randomUUID()
175
+ : `legacy-${createHash('sha256').update(`${session.id}\0${index}\0${JSON.stringify(session.messages[index])}`).digest('hex')}`);
176
+ }
177
+ const seen = new Set();
178
+ session.messageIds = ids.map((id) => {
179
+ const normalized = typeof id === 'string' && id.trim() ? id.trim() : randomUUID();
180
+ if (seen.has(normalized)) {
181
+ const replacement = randomUUID();
182
+ seen.add(replacement);
183
+ return replacement;
137
184
  }
185
+ seen.add(normalized);
186
+ return normalized;
187
+ });
188
+ return session;
189
+ }
190
+ readSessionFile(filePath) {
191
+ try {
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
195
+ }
196
+ catch {
197
+ parsed = JSON.parse(fs.readFileSync(`${filePath}.bak`, 'utf8'));
198
+ }
199
+ const fallbackCandidate = parsed && typeof parsed === 'object' && 'data' in parsed
200
+ ? parsed.data
201
+ : parsed;
202
+ const fallback = fallbackCandidate;
203
+ if (!fallback?.id || !fallback?.project || !Array.isArray(fallback.messages))
204
+ return null;
205
+ return this.ensureMessageIds(this.sessionStore(filePath, fallback).read());
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ readSessionsFromDir(directory) {
212
+ if (!directory)
213
+ return [];
214
+ try {
215
+ return fs.readdirSync(directory)
216
+ .filter((name) => name.endsWith('.json'))
217
+ .map((name) => {
218
+ return this.readSessionFile(path.join(directory, name));
219
+ })
220
+ .filter((session) => Boolean(session?.id && session?.project));
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ ensureDir() {
227
+ if (!fs.existsSync(this.sessionsDir)) {
228
+ fs.mkdirSync(this.sessionsDir, { recursive: true, mode: 0o700 });
229
+ }
230
+ if (process.platform !== 'win32') {
231
+ fs.chmodSync(this.sessionsDir, 0o700);
138
232
  }
139
233
  }
140
234
  /**
@@ -149,7 +243,7 @@ export class SessionManager {
149
243
  try {
150
244
  const now = Date.now();
151
245
  const files = fs.readdirSync(this.sessionsDir)
152
- .filter((f) => f.endsWith('.json'))
246
+ .filter((f) => f.endsWith('.json') && f !== 'index.json')
153
247
  .map((f) => {
154
248
  const full = path.join(this.sessionsDir, f);
155
249
  let mtimeMs = 0;
@@ -163,7 +257,7 @@ export class SessionManager {
163
257
  for (const entry of files) {
164
258
  if (entry.mtimeMs === 0 || now - entry.mtimeMs > this.retentionWindowMs) {
165
259
  try {
166
- fs.unlinkSync(entry.full);
260
+ this.delete(path.basename(entry.file, '.json'));
167
261
  }
168
262
  catch { /* ignore */ }
169
263
  }
@@ -175,7 +269,7 @@ export class SessionManager {
175
269
  if (remaining.length > this.maxRetainedSessions) {
176
270
  for (const entry of remaining.slice(this.maxRetainedSessions)) {
177
271
  try {
178
- fs.unlinkSync(entry.full);
272
+ this.delete(path.basename(entry.file, '.json'));
179
273
  }
180
274
  catch { /* ignore */ }
181
275
  }
@@ -193,6 +287,34 @@ export class SessionManager {
193
287
  const random = Math.random().toString(36).substring(2, 8);
194
288
  return `${timestamp}-${random}`;
195
289
  }
290
+ getProjectId(project, create) {
291
+ const workspaceDir = this.workspaceSessionsDir(project);
292
+ if (!workspaceDir)
293
+ return undefined;
294
+ const vigthoriaDir = path.dirname(workspaceDir);
295
+ const marker = path.join(vigthoriaDir, 'project-id');
296
+ try {
297
+ const existing = fs.readFileSync(marker, 'utf8').trim();
298
+ if (/^[a-f0-9-]{20,64}$/i.test(existing))
299
+ return existing.toLowerCase();
300
+ }
301
+ catch { /* marker not created yet */ }
302
+ if (!create)
303
+ return undefined;
304
+ fs.mkdirSync(vigthoriaDir, { recursive: true, mode: 0o700 });
305
+ const projectId = randomUUID();
306
+ try {
307
+ fs.writeFileSync(marker, `${projectId}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
308
+ secureFileMode(marker);
309
+ return projectId;
310
+ }
311
+ catch (error) {
312
+ if (error.code === 'EEXIST') {
313
+ return fs.readFileSync(marker, 'utf8').trim().toLowerCase();
314
+ }
315
+ throw error;
316
+ }
317
+ }
196
318
  /**
197
319
  * Create a new session
198
320
  */
@@ -203,12 +325,14 @@ export class SessionManager {
203
325
  project,
204
326
  model,
205
327
  messages: [],
328
+ messageIds: [],
206
329
  memorySummary: '',
207
330
  compactedAt: null,
208
331
  createdAt: new Date().toISOString(),
209
332
  updatedAt: new Date().toISOString(),
210
333
  agentMode,
211
334
  operatorMode,
335
+ projectId: this.getProjectId(project, true),
212
336
  };
213
337
  this.save(session);
214
338
  return session;
@@ -219,37 +343,89 @@ export class SessionManager {
219
343
  */
220
344
  save(session) {
221
345
  const compacted = this.compactSession(session);
222
- const targetSession = compacted.session;
346
+ const targetSession = this.ensureMessageIds(compacted.session);
223
347
  targetSession.updatedAt = new Date().toISOString();
348
+ targetSession.projectId = targetSession.projectId || this.getProjectId(targetSession.project, true);
224
349
  const filePath = path.join(this.sessionsDir, `${session.id}.json`);
225
- try {
226
- atomicWriteJson(filePath, targetSession, 0o600);
227
- secureFileMode(filePath);
228
- }
229
- catch (error) {
230
- // Surface a best-effort hint rather than crashing the active chat.
231
- if (process.env.VIGTHORIA_DEBUG === '1') {
232
- console.error('[vigthoria] session save failed:', error?.message ?? error);
350
+ const persisted = this.sessionStore(filePath, targetSession).update((current) => this.mergeConcurrentSession(current, targetSession)).data;
351
+ secureFileMode(filePath);
352
+ const mirrors = [];
353
+ // Mirror agent checkpoints into the bound workspace. This makes interrupted
354
+ // direct runs resumable when a packaged/sandboxed CLI cannot write the
355
+ // process home directory, and keeps state attached to the correct project
356
+ // across terminals and external client machines.
357
+ if (persisted.activeAgentRun || persisted.lastAgentRunOutcome) {
358
+ const workspaceDir = this.workspaceSessionsDir(persisted.project);
359
+ if (workspaceDir) {
360
+ fs.mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
361
+ const workspaceFile = path.join(workspaceDir, `${persisted.id}.json`);
362
+ this.sessionStore(workspaceFile, persisted).update((current) => this.mergeConcurrentSession(current, persisted));
363
+ secureFileMode(workspaceFile);
364
+ mirrors.push(workspaceFile);
233
365
  }
234
366
  }
367
+ this.indexStore.update((index) => ({
368
+ sessions: {
369
+ ...index.sessions,
370
+ [persisted.id]: {
371
+ projectId: persisted.projectId,
372
+ project: persisted.project,
373
+ mirrors: [...new Set([...(index.sessions[persisted.id]?.mirrors || []), ...mirrors])],
374
+ updatedAt: persisted.updatedAt,
375
+ },
376
+ },
377
+ }));
235
378
  this.pruneIfNeeded();
236
379
  }
380
+ mergeConcurrentSession(current, incoming) {
381
+ if (current.id !== incoming.id)
382
+ return incoming;
383
+ this.ensureMessageIds(current);
384
+ this.ensureMessageIds(incoming);
385
+ const compactedChanged = Boolean(incoming.compactedAt && incoming.compactedAt !== current.compactedAt);
386
+ const messages = compactedChanged ? incoming.messages : [...current.messages];
387
+ const messageIds = compactedChanged ? [...incoming.messageIds] : [...current.messageIds];
388
+ if (!compactedChanged) {
389
+ const indexById = new Map(messageIds.map((id, index) => [id, index]));
390
+ for (let index = 0; index < incoming.messages.length; index += 1) {
391
+ const message = incoming.messages[index];
392
+ const id = incoming.messageIds[index];
393
+ const existingIndex = indexById.get(id);
394
+ if (existingIndex === undefined) {
395
+ indexById.set(id, messages.length);
396
+ messageIds.push(id);
397
+ messages.push(message);
398
+ }
399
+ else {
400
+ messages[existingIndex] = message;
401
+ }
402
+ }
403
+ }
404
+ const checkpointTimes = [incoming.activeAgentRun, current.activeAgentRun]
405
+ .filter((checkpoint) => Boolean(checkpoint))
406
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
407
+ return {
408
+ ...current,
409
+ ...incoming,
410
+ messages,
411
+ messageIds,
412
+ activeAgentRun: checkpointTimes[0] || incoming.activeAgentRun || current.activeAgentRun,
413
+ lastAgentRunOutcome: incoming.lastAgentRunOutcome || current.lastAgentRunOutcome,
414
+ updatedAt: new Date(Math.max(Date.parse(current.updatedAt) || 0, Date.parse(incoming.updatedAt) || 0)).toISOString(),
415
+ };
416
+ }
237
417
  /**
238
418
  * Load session by ID
239
419
  */
240
420
  load(id) {
421
+ const indexRecord = this.indexStore.read().sessions[id];
422
+ if (indexRecord?.deletedAt)
423
+ return null;
241
424
  const filePath = path.join(this.sessionsDir, `${id}.json`);
242
425
  if (!fs.existsSync(filePath)) {
243
426
  return null;
244
427
  }
245
- try {
246
- const content = fs.readFileSync(filePath, 'utf-8');
247
- return JSON.parse(content);
248
- }
249
- catch (error) {
250
- console.warn(`Failed to load session ${id}:`, error);
251
- return null;
252
- }
428
+ return this.readSessionFile(filePath);
253
429
  }
254
430
  normalizeProjectKey(projectPath) {
255
431
  const raw = String(projectPath || '').trim();
@@ -273,24 +449,30 @@ export class SessionManager {
273
449
  * Get the most recent session for a project
274
450
  */
275
451
  getLatest(project) {
276
- const sessions = this.list();
452
+ const primarySessions = this.list()
453
+ .map((metadata) => this.load(metadata.id))
454
+ .filter((session) => Boolean(session));
455
+ const workspaceSessions = this.readSessionsFromDir(this.workspaceSessionsDir(project));
456
+ const sessionsById = new Map();
457
+ const index = this.indexStore.read();
458
+ for (const session of [...primarySessions, ...workspaceSessions]) {
459
+ if (index.sessions[session.id]?.deletedAt)
460
+ continue;
461
+ const existing = sessionsById.get(session.id);
462
+ if (!existing || new Date(session.updatedAt).getTime() >= new Date(existing.updatedAt).getTime()) {
463
+ sessionsById.set(session.id, session);
464
+ }
465
+ }
466
+ const sessions = [...sessionsById.values()];
277
467
  const projectKey = this.normalizeProjectKey(project);
278
- const projectBase = path.basename(projectKey || '').toLowerCase();
468
+ const projectId = this.getProjectId(project, false);
279
469
  const exactMatches = sessions
280
- .filter((s) => this.normalizeProjectKey(s.project) === projectKey)
470
+ .filter((s) => projectId && s.projectId
471
+ ? s.projectId === projectId
472
+ : this.normalizeProjectKey(s.project) === projectKey)
281
473
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
282
474
  if (exactMatches.length > 0) {
283
- return this.load(exactMatches[0].id);
284
- }
285
- // Fallback: if old sessions stored inconsistent slash/case forms, allow
286
- // latest same-folder match (e.g., C:\Proj and C:/proj) to preserve /retry UX.
287
- if (projectBase) {
288
- const softMatches = sessions
289
- .filter((s) => path.basename(this.normalizeProjectKey(s.project) || '').toLowerCase() === projectBase)
290
- .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
291
- if (softMatches.length > 0) {
292
- return this.load(softMatches[0].id);
293
- }
475
+ return exactMatches[0];
294
476
  }
295
477
  return null;
296
478
  }
@@ -301,16 +483,18 @@ export class SessionManager {
301
483
  list() {
302
484
  let files = [];
303
485
  try {
304
- files = fs.readdirSync(this.sessionsDir).filter(f => f.endsWith('.json'));
486
+ files = fs.readdirSync(this.sessionsDir).filter(f => f.endsWith('.json') && f !== 'index.json');
305
487
  }
306
488
  catch {
307
489
  return [];
308
490
  }
309
491
  return files.map(f => {
310
492
  try {
311
- const content = fs.readFileSync(path.join(this.sessionsDir, f), 'utf-8');
312
- const session = JSON.parse(content);
313
- const { messages, ...metadata } = session;
493
+ const session = this.readSessionFile(path.join(this.sessionsDir, f));
494
+ if (!session || this.indexStore.read().sessions[session.id]?.deletedAt)
495
+ return null;
496
+ const { messages, messageIds: _messageIds, ...metadata } = session;
497
+ void _messageIds;
314
498
  return { ...metadata, messages: [] };
315
499
  }
316
500
  catch (error) {
@@ -326,20 +510,73 @@ export class SessionManager {
326
510
  */
327
511
  delete(id) {
328
512
  const filePath = path.join(this.sessionsDir, `${id}.json`);
329
- if (fs.existsSync(filePath)) {
330
- fs.unlinkSync(filePath);
331
- return true;
513
+ const index = this.indexStore.read();
514
+ const record = index.sessions[id];
515
+ const targets = [filePath, ...(record?.mirrors || [])];
516
+ let removed = false;
517
+ for (const target of targets) {
518
+ if (path.basename(target) !== `${id}.json` || path.basename(path.dirname(target)) !== 'sessions')
519
+ continue;
520
+ try {
521
+ fs.unlinkSync(target);
522
+ removed = true;
523
+ }
524
+ catch (error) {
525
+ if (error.code !== 'ENOENT')
526
+ throw error;
527
+ }
332
528
  }
333
- return false;
529
+ this.indexStore.update((current) => ({
530
+ sessions: {
531
+ ...current.sessions,
532
+ [id]: {
533
+ projectId: record?.projectId,
534
+ project: record?.project || '',
535
+ mirrors: record?.mirrors || [],
536
+ updatedAt: record?.updatedAt || new Date().toISOString(),
537
+ deletedAt: new Date().toISOString(),
538
+ },
539
+ },
540
+ }));
541
+ return removed;
334
542
  }
335
543
  /**
336
544
  * Clear all sessions
337
545
  */
338
546
  clearAll() {
339
547
  const files = fs.readdirSync(this.sessionsDir)
340
- .filter(f => f.endsWith('.json'));
341
- files.forEach(f => fs.unlinkSync(path.join(this.sessionsDir, f)));
342
- return files.length;
548
+ .filter(f => f.endsWith('.json') && f !== 'index.json');
549
+ const ids = new Set([
550
+ ...files.map((file) => path.basename(file, '.json')),
551
+ ...Object.keys(this.indexStore.read().sessions),
552
+ ]);
553
+ let removed = 0;
554
+ for (const id of ids) {
555
+ if (this.delete(id))
556
+ removed += 1;
557
+ }
558
+ return removed;
559
+ }
560
+ markCancelledByContext(contextId) {
561
+ let updated = 0;
562
+ for (const metadata of this.list()) {
563
+ const session = this.load(metadata.id);
564
+ const checkpoint = session?.activeAgentRun;
565
+ if (!session || !checkpoint || checkpoint.contextId !== contextId)
566
+ continue;
567
+ if (!['submitted', 'retry', 'continue', 'running', 'failed'].includes(checkpoint.status))
568
+ continue;
569
+ assertAgentStatusTransition(checkpoint.status, 'cancelled');
570
+ session.activeAgentRun = {
571
+ ...checkpoint,
572
+ status: 'cancelled',
573
+ updatedAt: new Date().toISOString(),
574
+ error: 'Cancelled by user request.',
575
+ };
576
+ this.save(session);
577
+ updated += 1;
578
+ }
579
+ return updated;
343
580
  }
344
581
  /**
345
582
  * Add message to session
@@ -358,13 +595,16 @@ export class SessionManager {
358
595
  return [
359
596
  'Session memory summary from earlier conversation turns.',
360
597
  'Use this as compressed context for follow-up questions and resume behavior.',
361
- session.memorySummary.trim(),
598
+ redactSensitiveText(session.memorySummary.trim()),
362
599
  ].join('\n');
363
600
  }
364
601
  compactSession(session) {
602
+ this.ensureMessageIds(session);
365
603
  if (session.messages.length <= this.compactThreshold) {
366
604
  return { session, compacted: false };
367
605
  }
606
+ const priorMessages = [...session.messages];
607
+ const priorIds = [...session.messageIds];
368
608
  const systemMessages = session.messages.filter((message) => message.role === 'system' && this.isPersistentSystemMessage(message.content));
369
609
  const recentMessages = session.messages.slice(-this.retainRecentMessages);
370
610
  const olderMessages = session.messages.slice(0, -this.retainRecentMessages);
@@ -372,6 +612,10 @@ export class SessionManager {
372
612
  session.memorySummary = nextSummary;
373
613
  session.compactedAt = new Date().toISOString();
374
614
  session.messages = this.mergePersistentAndRecent(systemMessages, recentMessages);
615
+ session.messageIds = session.messages.map((message) => {
616
+ const index = priorMessages.indexOf(message);
617
+ return index >= 0 ? priorIds[index] : randomUUID();
618
+ });
375
619
  return { session, compacted: true };
376
620
  }
377
621
  isPersistentSystemMessage(content) {
@@ -434,7 +678,7 @@ export class SessionManager {
434
678
  return summaryLines.slice(-14).join('\n');
435
679
  }
436
680
  normalizeMessageContent(content) {
437
- const collapsed = String(content || '')
681
+ const collapsed = redactSensitiveText(String(content || ''))
438
682
  .replace(/<tool_call>[\s\S]*?<\/tool_call>/g, ' ')
439
683
  .replace(/\s+/g, ' ')
440
684
  .trim();
@@ -0,0 +1,17 @@
1
+ import type { Logger } from './logger.js';
2
+ import type { ApprovalScope } from './process-policy.js';
3
+ export declare class ToolApprovalService {
4
+ private readonly workspace;
5
+ private readonly logger;
6
+ private readonly sessionOperations;
7
+ private readonly store;
8
+ constructor(workspace: string, logger: Logger, permissionsFile?: string);
9
+ private report;
10
+ private read;
11
+ hasPersistent(scope: ApprovalScope): boolean;
12
+ hasSession(scope: ApprovalScope): boolean;
13
+ recordSession(scope: ApprovalScope): void;
14
+ recordPersistent(scope: ApprovalScope): void;
15
+ clearSession(): void;
16
+ sessionKeys(): string[];
17
+ }