@shawnstack/quickforge 1.7.7 → 1.7.8

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 (83) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/cloud/auto-approval.mjs +155 -0
  29. package/server/cloud/client.mjs +5 -0
  30. package/server/cloud/qf-agent-process.mjs +163 -3
  31. package/server/cloud/service-config.mjs +1 -1
  32. package/server/index.mjs +69 -13
  33. package/server/lan-access-backup.mjs +256 -0
  34. package/server/lan-access-cutover.mjs +306 -0
  35. package/server/lan-access-json-file.mjs +75 -0
  36. package/server/lan-access-service.mjs +155 -0
  37. package/server/lan-access-store.mjs +110 -9
  38. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  39. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  40. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  41. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  42. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  43. package/server/maintenance/export-session-state-v1.mjs +67 -0
  44. package/server/maintenance/export-share-v1.mjs +77 -0
  45. package/server/routes/agent.mjs +37 -4
  46. package/server/routes/backup.mjs +221 -39
  47. package/server/routes/cloud.mjs +28 -1
  48. package/server/routes/scheduled-tasks.mjs +347 -134
  49. package/server/routes/shares.mjs +4 -2
  50. package/server/routes/storage.mjs +138 -8
  51. package/server/routes/workspace.mjs +265 -4
  52. package/server/scheduled-runs-backup.mjs +235 -0
  53. package/server/scheduled-runs-cutover.mjs +497 -0
  54. package/server/scheduled-task-runs-service.mjs +279 -0
  55. package/server/session-index-service.mjs +495 -0
  56. package/server/session-state-backup.mjs +277 -0
  57. package/server/session-state-cutover.mjs +370 -0
  58. package/server/session-state-service.mjs +709 -0
  59. package/server/share-backup.mjs +255 -0
  60. package/server/share-cutover.mjs +296 -0
  61. package/server/share-json-file.mjs +60 -0
  62. package/server/share-service.mjs +163 -0
  63. package/server/share-store.mjs +196 -19
  64. package/server/skills.mjs +0 -1
  65. package/server/sqlite/database.mjs +233 -0
  66. package/server/sqlite/lan-access-repository.mjs +652 -0
  67. package/server/sqlite/migrations.mjs +577 -0
  68. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  69. package/server/sqlite/session-index-repository.mjs +297 -0
  70. package/server/sqlite/session-state-repository.mjs +786 -0
  71. package/server/sqlite/share-repository.mjs +854 -0
  72. package/server/storage.mjs +317 -1
  73. package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
  74. package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
  75. package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
  76. package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
  77. package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
  78. package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
  79. package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
  80. package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
  81. package/dist/assets/index-BTPzyq7d.js +0 -65
  82. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  83. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,163 @@
1
+ import { createShareRepository } from './sqlite/share-repository.mjs'
2
+ import { getSqliteStorage } from './sqlite/database.mjs'
3
+ import { materializeShareJsonEntry } from './share-json-file.mjs'
4
+
5
+ // F10 share storage phases — the same state machine as F8 session state but in
6
+ // its own storage domain (`share_storage_state`, independent maintenance lock).
7
+ export const SHARE_STORAGE_PHASES = Object.freeze({
8
+ JSON_AUTHORITATIVE: 'json_authoritative',
9
+ CUTOVER_RUNNING: 'cutover_running',
10
+ JSON_PENDING: 'sqlite_authoritative_json_pending',
11
+ AUTHORITATIVE: 'authoritative',
12
+ })
13
+
14
+ let repositoryInstance = null
15
+ let cachedPhase = SHARE_STORAGE_PHASES.JSON_AUTHORITATIVE
16
+ let jsonAdapter = null
17
+ let mirrorAdapter = null
18
+ let drainPromise = null
19
+ let mirrorTimer = null
20
+ const MIRROR_DRAIN_INTERVAL_MS = 1000
21
+
22
+ function storage() {
23
+ return getSqliteStorage()
24
+ }
25
+
26
+ function repository() {
27
+ if (!repositoryInstance) repositoryInstance = createShareRepository(storage())
28
+ return repositoryInstance
29
+ }
30
+
31
+ // JSON-authoritative read/write adapter slot (same pattern as
32
+ // session-state-service). share-store keeps its legacy JSON path, so this is a
33
+ // public extension point rather than an internal call site.
34
+ export function requireShareJsonAdapter(method) {
35
+ if (typeof jsonAdapter?.[method] !== 'function') throw new Error(`JSON authoritative share adapter does not implement ${method}`)
36
+ return jsonAdapter[method].bind(jsonAdapter)
37
+ }
38
+
39
+ function sqliteReadable() {
40
+ return cachedPhase === SHARE_STORAGE_PHASES.JSON_PENDING || cachedPhase === SHARE_STORAGE_PHASES.AUTHORITATIVE
41
+ }
42
+
43
+ export function createDefaultShareMirror() {
44
+ return {
45
+ async upsert(record) {
46
+ await materializeShareJsonEntry({ operation: 'upsert', record })
47
+ },
48
+ async delete(shareId) {
49
+ await materializeShareJsonEntry({ operation: 'delete', shareId })
50
+ },
51
+ }
52
+ }
53
+
54
+ export function configureShareService({ repository: configuredRepository, mirror, phase, json } = {}) {
55
+ if (configuredRepository !== undefined) repositoryInstance = configuredRepository
56
+ if (json !== undefined) jsonAdapter = json
57
+ if (mirror !== undefined) mirrorAdapter = mirror
58
+ if (phase !== undefined) {
59
+ if (!Object.values(SHARE_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid share storage phase: ${phase}`)
60
+ cachedPhase = phase
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The active share repository (lazily created from the SQLite storage handle).
66
+ * Only callable while the share storage is SQLite-readable (pending or
67
+ * authoritative); JSON-authoritative phases keep the legacy JSON store path.
68
+ */
69
+ export function getShareRepository() {
70
+ return repository()
71
+ }
72
+
73
+ export function setShareStoragePhase(phase, values = {}) {
74
+ if (!Object.values(SHARE_STORAGE_PHASES).includes(phase)) throw new TypeError(`Invalid share storage phase: ${phase}`)
75
+ const updatedAt = new Date().toISOString()
76
+ storage().prepare(`UPDATE share_storage_state SET phase = ?, share_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ? WHERE singleton = 1`)
77
+ .run(phase, values.shareCount ?? null, values.digest ?? null, values.backupFile ?? null, values.diagnostic ? JSON.stringify(values.diagnostic) : null, updatedAt)
78
+ cachedPhase = phase
79
+ return readShareStorageState()
80
+ }
81
+
82
+ export function readShareStorageState() {
83
+ const row = storage().prepare('SELECT * FROM share_storage_state WHERE singleton = 1').get()
84
+ if (!row) throw new Error('Share storage state is missing')
85
+ cachedPhase = row.phase
86
+ return {
87
+ phase: row.phase,
88
+ shareCount: row.share_count === null ? null : Number(row.share_count),
89
+ digest: row.digest,
90
+ backupFile: row.backup_file,
91
+ diagnostic: row.diagnostic_json ? JSON.parse(row.diagnostic_json) : null,
92
+ updatedAt: row.updated_at,
93
+ }
94
+ }
95
+
96
+ export function initializeShareService() {
97
+ return readShareStorageState()
98
+ }
99
+
100
+ export function getShareStoragePhase() {
101
+ return cachedPhase
102
+ }
103
+
104
+ export function isShareStorageAuthoritative() {
105
+ return sqliteReadable()
106
+ }
107
+
108
+ function scheduleShareJsonMirrorDrain() {
109
+ if (!sqliteReadable() || !mirrorAdapter || mirrorTimer) return
110
+ mirrorTimer = setTimeout(() => {
111
+ mirrorTimer = null
112
+ void drainShareJsonMirror().then(({ pending }) => {
113
+ if (pending > 0) scheduleShareJsonMirrorDrain()
114
+ }).catch(() => {
115
+ scheduleShareJsonMirrorDrain()
116
+ })
117
+ }, MIRROR_DRAIN_INTERVAL_MS)
118
+ mirrorTimer.unref?.()
119
+ }
120
+
121
+ export function requestShareJsonMirrorDrain() {
122
+ scheduleShareJsonMirrorDrain()
123
+ }
124
+
125
+ export function stopShareService() {
126
+ if (mirrorTimer) clearTimeout(mirrorTimer)
127
+ mirrorTimer = null
128
+ }
129
+
130
+ export async function drainShareJsonMirror() {
131
+ if (!sqliteReadable() || !mirrorAdapter) return { pending: 0, drained: 0, failed: 0 }
132
+ if (drainPromise) return drainPromise
133
+ drainPromise = (async () => {
134
+ let drained = 0
135
+ let failed = 0
136
+ for (;;) {
137
+ const entries = repository().listMirrorQueue()
138
+ if (entries.length === 0) break
139
+ let progressed = false
140
+ for (const entry of entries) {
141
+ try {
142
+ if (entry.operation === 'upsert') await mirrorAdapter.upsert(entry.record)
143
+ else await mirrorAdapter.delete(entry.shareId)
144
+ repository().acknowledgeMirror(entry)
145
+ drained += 1
146
+ progressed = true
147
+ } catch (error) {
148
+ repository().failMirror(entry, error)
149
+ failed += 1
150
+ }
151
+ }
152
+ if (!progressed) break
153
+ }
154
+ return { pending: repository().listMirrorQueue().length, drained, failed }
155
+ })().finally(() => {
156
+ drainPromise = null
157
+ })
158
+ return drainPromise
159
+ }
160
+
161
+ export function listShareMirrorQueue() {
162
+ return repository().listMirrorQueue()
163
+ }
@@ -4,6 +4,8 @@ import { promises as fs } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { ensureStorage, storageDir } from './storage.mjs'
6
6
  import { hashPassword, safeHashEqual, sha256Base64Url, verifyPassword } from './utils/password-auth.mjs'
7
+ import { getShareRepository, isShareStorageAuthoritative, requestShareJsonMirrorDrain } from './share-service.mjs'
8
+ import { isShareMaintenanceActive } from './share-cutover.mjs'
7
9
  const SHARE_ID_PREFIX = 'qfs_'
8
10
  const SHARE_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
9
11
  const MAX_SHARE_TOKENS = 50
@@ -23,6 +25,24 @@ function emitConversationShareInvalidated(shareId, reason) {
23
25
  shareLifecycleEvents.emit('invalidated', { shareId, reason })
24
26
  }
25
27
 
28
+ // F10 Phase 2: share writes are SQLite-authoritative once the share storage is
29
+ // pending/authoritative. The JSON file degrades to a best-effort mirror fed by
30
+ // the share mirror queue; json_authoritative/cutover_running keep the legacy
31
+ // JSON read/write path below.
32
+ function repositoryActive() {
33
+ return isShareStorageAuthoritative()
34
+ }
35
+
36
+ function assertShareWritesAllowed() {
37
+ if (!repositoryActive()) return
38
+ if (isShareMaintenanceActive()) {
39
+ const error = new Error('Share storage is under maintenance')
40
+ error.statusCode = 423
41
+ error.errorCode = 'SHARE_MAINTENANCE_ACTIVE'
42
+ throw error
43
+ }
44
+ }
45
+
26
46
  function clearShareTokens(record) {
27
47
  record.authVersion = (record.authVersion || 1) + 1
28
48
  record.tokens = []
@@ -137,7 +157,7 @@ export function createShareToken(shareId) {
137
157
  }
138
158
  }
139
159
 
140
- function pruneShareTokens(tokens, now = Date.now()) {
160
+ function pruneTokenRecords(tokens, now = Date.now()) {
141
161
  return (Array.isArray(tokens) ? tokens : [])
142
162
  .filter((tokenRecord) => {
143
163
  if (!tokenRecord?.tokenHash) return false
@@ -149,11 +169,12 @@ function pruneShareTokens(tokens, now = Date.now()) {
149
169
 
150
170
  export function verifyShareToken(record, token) {
151
171
  if (!record || !token || typeof token !== 'string') return false
172
+ if (repositoryActive()) return getShareRepository().verifyToken(record, token)
152
173
  const [tokenShareId, secret] = token.split('.')
153
174
  if (tokenShareId !== record.id || !secret) return false
154
175
  const actualHash = sha256Base64Url(secret)
155
176
  const authVersion = record.authVersion || 1
156
- const tokenRecords = pruneShareTokens(record.tokens)
177
+ const tokenRecords = pruneTokenRecords(record.tokens)
157
178
 
158
179
  if (record.tokenHash) {
159
180
  tokenRecords.push({ tokenHash: record.tokenHash, authVersion: record.authVersion || 1 })
@@ -238,6 +259,59 @@ export async function createConversationShare({
238
259
  const normalizedPassword = passwordProvided ? password.trim() : undefined
239
260
  const passwordInfo = passwordProvided ? await hashSharePassword(normalizedPassword) : {}
240
261
  return enqueueWrite(writeQueueName, async () => {
262
+ assertShareWritesAllowed()
263
+ if (repositoryActive()) {
264
+ const repository = getShareRepository()
265
+ const existingShares = repository.list({ sessionId, includeRevoked: true })
266
+ const current = existingShares.find((record) => !record.supersededAt)
267
+
268
+ if (permission === 'operate') {
269
+ const willHavePassword = passwordProvided ? Boolean(normalizedPassword) : Boolean(current?.passwordHash)
270
+ if (!willHavePassword) {
271
+ const error = new Error('Editable shares require a non-empty password')
272
+ error.statusCode = 400
273
+ throw error
274
+ }
275
+ }
276
+
277
+ const timestamp = new Date().toISOString()
278
+ // Preserve the existing password when the client did not send a new one
279
+ // (matching the legacy JSON create), and keep access counters intact.
280
+ const existingPassword = current?.passwordHash
281
+ ? { passwordHash: current.passwordHash, passwordSalt: current.passwordSalt, passwordVersion: current.passwordVersion }
282
+ : {}
283
+ const created = repository.create({
284
+ id: current?.id || generateShareId(),
285
+ sessionId,
286
+ permission,
287
+ titleSnapshot: titleSnapshot || current?.titleSnapshot || 'New chat',
288
+ scope: scope === 'project' ? 'project' : 'global',
289
+ projectId: scope === 'project' ? projectId : undefined,
290
+ allowCloudUsage: permission === 'operate' && allowCloudUsage === true,
291
+ expiresAt: expiresAt || undefined,
292
+ createdAt: current?.createdAt || timestamp,
293
+ updatedAt: timestamp,
294
+ accessCount: current?.accessCount ?? 0,
295
+ lastAccessedAt: current?.lastAccessedAt,
296
+ createdFromHost,
297
+ lastUpdatedFromHost: createdFromHost,
298
+ ...(passwordProvided ? passwordInfo : existingPassword),
299
+ })
300
+
301
+ for (const record of existingShares) {
302
+ if (record.id !== current?.id && !record.supersededAt) emitConversationShareInvalidated(record.id, 'superseded')
303
+ }
304
+ if (current) {
305
+ const lifecycleChanged = Boolean(current.revokedAt)
306
+ || String(current.expiresAt || '') !== String(created.expiresAt || '')
307
+ || current.allowCloudUsage === true !== (created.allowCloudUsage === true)
308
+ || (passwordProvided && (current.authVersion || 1) !== (created.authVersion || 1))
309
+ if (lifecycleChanged) emitConversationShareInvalidated(current.id, 'updated')
310
+ }
311
+ requestShareJsonMirrorDrain()
312
+ return publicShareRecord(created)
313
+ }
314
+
241
315
  const data = await readShareStoreFile()
242
316
  const now = new Date().toISOString()
243
317
  const existingRecords = Object.values(data)
@@ -336,11 +410,16 @@ export async function createConversationShare({
336
410
 
337
411
  export async function readConversationShare(shareId) {
338
412
  assertSafeShareId(shareId)
413
+ if (repositoryActive()) return getShareRepository().get(shareId)
339
414
  const data = await readShareStoreFile()
340
415
  return data[shareId] || null
341
416
  }
342
417
 
343
418
  export async function listConversationShares(sessionId) {
419
+ if (repositoryActive()) {
420
+ const records = getShareRepository().list({ sessionId: sessionId || undefined, includeRevoked: true })
421
+ return records.map(publicShareRecord)
422
+ }
344
423
  const data = await readShareStoreFile()
345
424
  return Object.values(data)
346
425
  .filter((record) => !sessionId || record.sessionId === sessionId)
@@ -352,6 +431,21 @@ export async function listConversationShares(sessionId) {
352
431
  export async function revokeConversationShare(shareId) {
353
432
  assertSafeShareId(shareId)
354
433
  return enqueueWrite(writeQueueName, async () => {
434
+ assertShareWritesAllowed()
435
+ if (repositoryActive()) {
436
+ const repository = getShareRepository()
437
+ const record = repository.get(shareId)
438
+ if (!record) {
439
+ const error = new Error('Share not found')
440
+ error.statusCode = 404
441
+ throw error
442
+ }
443
+ const wasActive = !record.revokedAt
444
+ const revoked = repository.revoke(shareId, {})
445
+ requestShareJsonMirrorDrain()
446
+ if (wasActive) emitConversationShareInvalidated(shareId, 'revoked')
447
+ return publicShareRecord(revoked)
448
+ }
355
449
  const data = await readShareStoreFile()
356
450
  const record = data[shareId]
357
451
  if (!record) {
@@ -378,6 +472,12 @@ export async function restoreConversationShare(shareId, expiresAt) {
378
472
  throw error
379
473
  }
380
474
  return enqueueWrite(writeQueueName, async () => {
475
+ assertShareWritesAllowed()
476
+ if (repositoryActive()) {
477
+ const restored = getShareRepository().restore(shareId, { expiresAt })
478
+ requestShareJsonMirrorDrain()
479
+ return publicShareRecord(restored)
480
+ }
381
481
  const data = await readShareStoreFile()
382
482
  const record = data[shareId]
383
483
  if (!record) {
@@ -408,6 +508,13 @@ export async function updateConversationShareExpiration(shareId, expiresAt) {
408
508
  throw error
409
509
  }
410
510
  return enqueueWrite(writeQueueName, async () => {
511
+ assertShareWritesAllowed()
512
+ if (repositoryActive()) {
513
+ const updated = getShareRepository().update(shareId, { expiresAt: expiresAt || null })
514
+ requestShareJsonMirrorDrain()
515
+ emitConversationShareInvalidated(shareId, 'updated')
516
+ return publicShareRecord(updated)
517
+ }
411
518
  const data = await readShareStoreFile()
412
519
  const record = data[shareId]
413
520
  if (!record) {
@@ -441,6 +548,29 @@ export async function updateConversationShare(shareId, { permission, password, e
441
548
  }
442
549
  const passwordInfo = passwordProvided ? await hashSharePassword(normalizedPassword) : {}
443
550
  return enqueueWrite(writeQueueName, async () => {
551
+ assertShareWritesAllowed()
552
+ if (repositoryActive()) {
553
+ const repository = getShareRepository()
554
+ const existing = repository.get(shareId)
555
+ if (!existing) {
556
+ const error = new Error('Share not found')
557
+ error.statusCode = 404
558
+ throw error
559
+ }
560
+ const updated = repository.update(shareId, {
561
+ permission,
562
+ allowCloudUsage,
563
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
564
+ ...passwordInfo,
565
+ })
566
+ requestShareJsonMirrorDrain()
567
+ const lifecycleChanged = (permission !== undefined && permission !== existing.permission)
568
+ || (expiresAt !== undefined && String(existing.expiresAt || '') !== String(expiresAt || ''))
569
+ || (allowCloudUsage !== undefined && (existing.allowCloudUsage === true) !== (allowCloudUsage === true))
570
+ || passwordProvided
571
+ if (lifecycleChanged) emitConversationShareInvalidated(shareId, 'updated')
572
+ return publicShareRecord(updated)
573
+ }
444
574
  const data = await readShareStoreFile()
445
575
  const record = data[shareId]
446
576
  if (!record) {
@@ -488,6 +618,20 @@ export async function updateConversationShare(shareId, { permission, password, e
488
618
  export async function deleteConversationShare(shareId) {
489
619
  assertSafeShareId(shareId)
490
620
  return enqueueWrite(writeQueueName, async () => {
621
+ assertShareWritesAllowed()
622
+ if (repositoryActive()) {
623
+ const repository = getShareRepository()
624
+ const record = repository.get(shareId)
625
+ if (!record) {
626
+ const error = new Error('Share not found')
627
+ error.statusCode = 404
628
+ throw error
629
+ }
630
+ repository.delete(shareId, {})
631
+ requestShareJsonMirrorDrain()
632
+ emitConversationShareInvalidated(shareId, 'deleted')
633
+ return publicShareRecord(record)
634
+ }
491
635
  const data = await readShareStoreFile()
492
636
  const record = data[shareId]
493
637
  if (!record) {
@@ -505,13 +649,19 @@ export async function deleteConversationShare(shareId) {
505
649
  export async function issueConversationShareToken(shareId) {
506
650
  assertSafeShareId(shareId)
507
651
  return enqueueWrite(writeQueueName, async () => {
652
+ assertShareWritesAllowed()
653
+ if (repositoryActive()) {
654
+ const result = getShareRepository().issueToken(shareId, {})
655
+ requestShareJsonMirrorDrain()
656
+ return { token: result.token, share: publicShareRecord(result.share) }
657
+ }
508
658
  const data = await readShareStoreFile()
509
659
  const record = data[shareId]
510
660
  assertShareActive(record)
511
661
  const { token, tokenHash } = createShareToken(shareId)
512
662
  const issuedAt = new Date().toISOString()
513
663
  const expiresAt = new Date(Date.now() + SHARE_TOKEN_MAX_AGE_MS).toISOString()
514
- record.tokens = pruneShareTokens(record.tokens)
664
+ record.tokens = pruneTokenRecords(record.tokens)
515
665
  record.tokens.push({ tokenHash, issuedAt, expiresAt, authVersion: record.authVersion || 1 })
516
666
  record.tokens = record.tokens.slice(-MAX_SHARE_TOKENS)
517
667
  record.tokenHash = undefined
@@ -525,6 +675,35 @@ export async function issueConversationShareToken(shareId) {
525
675
  })
526
676
  }
527
677
 
678
+ // Prune expired share tokens. Authoritative: a repository transaction that
679
+ // deletes expired rows and bumps the share revision/mirror when anything was
680
+ // removed. JSON: best-effort in-place prune of the legacy store.
681
+ export async function pruneShareTokens(shareId) {
682
+ assertSafeShareId(shareId)
683
+ if (repositoryActive()) {
684
+ return enqueueWrite(writeQueueName, async () => {
685
+ assertShareWritesAllowed()
686
+ const removed = getShareRepository().pruneTokens(shareId, {})
687
+ requestShareJsonMirrorDrain()
688
+ return removed
689
+ })
690
+ }
691
+ return enqueueWrite(writeQueueName, async () => {
692
+ const data = await readShareStoreFile()
693
+ const record = data[shareId]
694
+ if (!record) return 0
695
+ const pruned = pruneTokenRecords(record.tokens)
696
+ const removed = (Array.isArray(record.tokens) ? record.tokens.length : 0) - pruned.length
697
+ if (removed > 0) {
698
+ record.tokens = pruned
699
+ record.updatedAt = new Date().toISOString()
700
+ data[shareId] = record
701
+ await writeShareStoreFile(data)
702
+ }
703
+ return removed
704
+ })
705
+ }
706
+
528
707
  function messageTimestampMs(message) {
529
708
  const timestamp = message?.timestamp
530
709
  if (typeof timestamp === 'number' && Number.isFinite(timestamp)) return timestamp
@@ -549,7 +728,7 @@ function lastModifiedFromMessages(messages, fallback) {
549
728
  }
550
729
 
551
730
  export async function rollbackSharedSessionMessages(record, rollbackMessageIndex) {
552
- const { readSessionValue, writeSessionValue, atomicUpdate } = await import('./storage.mjs')
731
+ const { readSessionValue, atomicSessionRecordUpdate } = await import('./storage.mjs')
553
732
  const { rollbackSessionMessages, rollbackStartIndexFromMessage } = await import('./agent-manager.mjs')
554
733
  const session = await readSessionValue(record.sessionId)
555
734
  if (!session) {
@@ -573,23 +752,21 @@ export async function rollbackSharedSessionMessages(record, rollbackMessageIndex
573
752
 
574
753
  const nextMessages = messages.slice(0, rollbackIndex)
575
754
  const lastModified = lastModifiedFromMessages(nextMessages, session.createdAt || session.lastModified)
576
- await writeSessionValue(record.sessionId, {
577
- ...session,
578
- messages: nextMessages,
579
- lastModified,
580
- })
581
- await atomicUpdate('sessions-metadata', (metadata) => {
582
- const existing = metadata[record.sessionId]
583
- if (existing) {
584
- metadata[record.sessionId] = {
585
- ...existing,
586
- messageCount: nextMessages.length,
587
- lastModified,
588
- preview: previewFromMessages(nextMessages),
589
- }
755
+ // Body + metadata roll back as one atomic session record update (single
756
+ // SQLite transaction when authoritative), so the shared page never observes
757
+ // a body rolled back while metadata still counts the removed messages.
758
+ const updated = await atomicSessionRecordUpdate(record.sessionId, ({ state, metadata }) => {
759
+ if (state.archivedAt) return null
760
+ return {
761
+ state: { ...state, messages: nextMessages, lastModified },
762
+ metadata: { ...metadata, messageCount: nextMessages.length, lastModified, preview: previewFromMessages(nextMessages) },
590
763
  }
591
- return metadata
592
764
  })
765
+ if (!updated) {
766
+ const error = new Error('Session not found')
767
+ error.statusCode = 404
768
+ throw error
769
+ }
593
770
  return { session: { ...session, messages: nextMessages, lastModified }, rollbackIndex }
594
771
  }
595
772
 
package/server/skills.mjs CHANGED
@@ -256,7 +256,6 @@ function skillFromStandardMarkdown(rootDir, source, text) {
256
256
  const name = normalizeSkillName(rawManifest.name)
257
257
  const description = normalizeString(rawManifest.description)
258
258
  if (!name || !isValidSkillName(name)) return null
259
- if (name !== normalizeSkillName(path.basename(rootDir))) return null
260
259
  if (!description || description.length > 1024) return null
261
260
 
262
261
  const metadata = normalizeMetadata(rawManifest.metadata)