@crouter/api 0.3.387 → 0.3.389

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 (131) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/api/dto/config.d.ts +11 -1
  5. package/dist/core/asset-root.d.ts +7 -0
  6. package/dist/core/asset-root.js +18 -0
  7. package/dist/core/canvas/boot-id.d.ts +6 -0
  8. package/dist/core/canvas/boot-id.js +26 -0
  9. package/dist/core/canvas/paths.d.ts +72 -0
  10. package/dist/core/canvas/paths.js +163 -0
  11. package/dist/core/canvas/pid.d.ts +391 -0
  12. package/dist/core/canvas/pid.js +948 -0
  13. package/dist/core/command-plugins/bundle.d.ts +149 -0
  14. package/dist/core/command-plugins/bundle.js +588 -0
  15. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  16. package/dist/core/command-plugins/endpoint.js +51 -0
  17. package/dist/core/config.d.ts +233 -0
  18. package/dist/core/config.js +1120 -0
  19. package/dist/core/env-name.d.ts +6 -0
  20. package/dist/core/env-name.js +9 -0
  21. package/dist/core/errors.d.ts +38 -0
  22. package/dist/core/errors.js +90 -0
  23. package/dist/core/events/emit.d.ts +6 -0
  24. package/dist/core/events/emit.js +42 -0
  25. package/dist/core/events/envelope.d.ts +2 -0
  26. package/dist/core/events/envelope.js +84 -0
  27. package/dist/core/events/errors.d.ts +4 -0
  28. package/dist/core/events/errors.js +69 -0
  29. package/dist/core/events/operation-id.d.ts +4 -0
  30. package/dist/core/events/operation-id.js +24 -0
  31. package/dist/core/events/serialize.d.ts +4 -0
  32. package/dist/core/events/serialize.js +199 -0
  33. package/dist/core/events/source.d.ts +16 -0
  34. package/dist/core/events/source.js +31 -0
  35. package/dist/core/events/types.d.ts +68 -0
  36. package/dist/core/events/types.js +11 -0
  37. package/dist/core/exclusive-lock.d.ts +34 -0
  38. package/dist/core/exclusive-lock.js +197 -0
  39. package/dist/core/fs-utils.d.ts +44 -0
  40. package/dist/core/fs-utils.js +208 -0
  41. package/dist/core/help.d.ts +309 -0
  42. package/dist/core/help.js +406 -0
  43. package/dist/core/human/page-catalog.d.ts +57 -0
  44. package/dist/core/human/page-catalog.js +172 -0
  45. package/dist/core/installed-plugins.d.ts +2 -0
  46. package/dist/core/installed-plugins.js +79 -0
  47. package/dist/core/io.d.ts +122 -0
  48. package/dist/core/io.js +373 -0
  49. package/dist/core/keybindings/attach-control.d.ts +49 -0
  50. package/dist/core/keybindings/attach-control.js +42 -0
  51. package/dist/core/keybindings/catalog.d.ts +18 -0
  52. package/dist/core/keybindings/catalog.js +257 -0
  53. package/dist/core/keybindings/types.d.ts +42 -0
  54. package/dist/core/keybindings/types.js +1 -0
  55. package/dist/core/layout.d.ts +26 -0
  56. package/dist/core/layout.js +94 -0
  57. package/dist/core/locked-file.d.ts +27 -0
  58. package/dist/core/locked-file.js +118 -0
  59. package/dist/core/log.d.ts +9 -0
  60. package/dist/core/log.js +89 -0
  61. package/dist/core/manifest.d.ts +5 -0
  62. package/dist/core/manifest.js +15 -0
  63. package/dist/core/plugin-env.d.ts +8 -0
  64. package/dist/core/plugin-env.js +31 -0
  65. package/dist/core/plugin-extensions.d.ts +29 -0
  66. package/dist/core/plugin-extensions.js +191 -0
  67. package/dist/core/plugin-swap-lock.d.ts +9 -0
  68. package/dist/core/plugin-swap-lock.js +31 -0
  69. package/dist/core/preview-result-path.d.ts +4 -0
  70. package/dist/core/preview-result-path.js +26 -0
  71. package/dist/core/profiles/env-store.d.ts +22 -0
  72. package/dist/core/profiles/env-store.js +163 -0
  73. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  74. package/dist/core/profiles/fuzzy-match.js +92 -0
  75. package/dist/core/profiles/manifest.d.ts +120 -0
  76. package/dist/core/profiles/manifest.js +529 -0
  77. package/dist/core/rate-limit-scope.d.ts +25 -0
  78. package/dist/core/rate-limit-scope.js +64 -0
  79. package/dist/core/render.d.ts +12 -0
  80. package/dist/core/render.js +138 -0
  81. package/dist/core/resolver.d.ts +14 -0
  82. package/dist/core/resolver.js +111 -0
  83. package/dist/core/runtime/branded-host.d.ts +25 -0
  84. package/dist/core/runtime/branded-host.js +264 -0
  85. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  86. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  87. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  88. package/dist/core/runtime/broker/signal-stream.js +149 -0
  89. package/dist/core/scope.d.ts +32 -0
  90. package/dist/core/scope.js +184 -0
  91. package/dist/core/scoped-state/db.d.ts +17 -0
  92. package/dist/core/scoped-state/db.js +247 -0
  93. package/dist/core/scoped-state/migrate.d.ts +8 -0
  94. package/dist/core/scoped-state/migrate.js +187 -0
  95. package/dist/core/scoped-state/paths.d.ts +9 -0
  96. package/dist/core/scoped-state/paths.js +27 -0
  97. package/dist/core/scoped-state/profiles.d.ts +27 -0
  98. package/dist/core/scoped-state/profiles.js +93 -0
  99. package/dist/core/scoped-state/providers.d.ts +24 -0
  100. package/dist/core/scoped-state/providers.js +19 -0
  101. package/dist/core/scoped-state/schema.d.ts +6 -0
  102. package/dist/core/scoped-state/schema.js +43 -0
  103. package/dist/core/scoped-state/settings.d.ts +28 -0
  104. package/dist/core/scoped-state/settings.js +83 -0
  105. package/dist/core/spaces/open-beneath.d.ts +71 -0
  106. package/dist/core/spaces/open-beneath.js +581 -0
  107. package/dist/core/sqlite-statements.d.ts +4 -0
  108. package/dist/core/sqlite-statements.js +17 -0
  109. package/dist/core/subscription-state.d.ts +121 -0
  110. package/dist/core/subscription-state.js +287 -0
  111. package/dist/core/user-settings.d.ts +377 -0
  112. package/dist/core/user-settings.js +458 -0
  113. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  114. package/dist/daemon/broker-signals/bus.js +87 -0
  115. package/dist/daemon/manage.d.ts +176 -0
  116. package/dist/daemon/manage.js +664 -0
  117. package/dist/daemon/pidfile.d.ts +8 -0
  118. package/dist/daemon/pidfile.js +37 -0
  119. package/dist/daemon/startup-policy.d.ts +1 -0
  120. package/dist/daemon/startup-policy.js +1 -0
  121. package/dist/native/linux.d.ts +29 -0
  122. package/dist/native/linux.js +20 -0
  123. package/dist/shared/env.d.ts +116 -0
  124. package/dist/shared/env.js +271 -0
  125. package/dist/shared/inbox-entry-body.d.ts +22 -0
  126. package/dist/shared/inbox-entry-body.js +116 -0
  127. package/dist/shared/working-activity.d.ts +9 -0
  128. package/dist/shared/working-activity.js +27 -0
  129. package/dist/types.d.ts +562 -0
  130. package/dist/types.js +186 -0
  131. package/package.json +1 -1
@@ -0,0 +1,247 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { forgetPrepared } from '../sqlite-statements.js';
3
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import lockfile from 'proper-lockfile';
6
+ import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
7
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
8
+ import { layoutNeedsMigration } from '../layout.js';
9
+ import { providerStore, projectStore, userStore } from './paths.js';
10
+ import { createScopedSchema, ensureProviderModelCooldowns, PROVIDER_SCHEMA_VERSION, SCOPED_SCHEMA_VERSION } from './schema.js';
11
+ const handles = new Map();
12
+ export const PROFILE_ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
13
+ export function writeOwnerRoot(db, key, root) {
14
+ db.prepare('UPDATE scope_owners SET root=? WHERE owner_key=?').run(root, key);
15
+ }
16
+ function version(db) {
17
+ return Number(db.prepare('PRAGMA user_version').get().user_version);
18
+ }
19
+ function assertVersion(db, store) {
20
+ const found = version(db);
21
+ if (found === 0)
22
+ throw new Error(`scoped SQL store is uninitialized: ${store.path}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
23
+ const expected = store.kind === 'provider' ? PROVIDER_SCHEMA_VERSION : SCOPED_SCHEMA_VERSION;
24
+ if (found > expected)
25
+ throw new Error(`scoped SQL store ${store.path} uses unsupported schema version ${found}`);
26
+ if (found !== expected)
27
+ throw new Error(`scoped SQL store ${store.path} requires an explicit conversion`);
28
+ }
29
+ function upgradeScopedStore(db, store) {
30
+ if (store.kind === 'provider') {
31
+ if (version(db) === PROVIDER_SCHEMA_VERSION) {
32
+ db.exec('PRAGMA busy_timeout = 5000');
33
+ ensureProviderModelCooldowns(db);
34
+ }
35
+ return;
36
+ }
37
+ if (version(db) >= SCOPED_SCHEMA_VERSION)
38
+ return;
39
+ db.exec('PRAGMA busy_timeout = 5000; BEGIN IMMEDIATE');
40
+ try {
41
+ if (version(db) === 1) {
42
+ const columns = db.prepare('PRAGMA table_info(profiles)').all();
43
+ if (!columns.some(({ name }) => name === 'grantee'))
44
+ db.exec('ALTER TABLE profiles ADD COLUMN grantee TEXT');
45
+ db.exec('PRAGMA user_version = 2');
46
+ }
47
+ if (version(db) === 2) {
48
+ const columns = db.prepare('PRAGMA table_info(profiles)').all();
49
+ if (columns.some(({ name }) => name === 'home'))
50
+ db.exec('ALTER TABLE profiles DROP COLUMN home');
51
+ db.exec('PRAGMA user_version = 3');
52
+ }
53
+ if (version(db) === 3) {
54
+ db.exec("ALTER TABLE profiles ADD COLUMN delivery_limits_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(delivery_limits_json))");
55
+ db.exec('PRAGMA user_version = 4');
56
+ }
57
+ if (version(db) === 4) {
58
+ db.exec(`CREATE TABLE IF NOT EXISTS legacy_project_caps (profile_id TEXT NOT NULL, path TEXT NOT NULL, memory TEXT NOT NULL CHECK(memory IN ('none','name','preview')), PRIMARY KEY(profile_id,path))`);
59
+ const columns = db.prepare('PRAGMA table_info(profile_projects)').all();
60
+ if (columns.some(({ name }) => name === 'memory')) {
61
+ db.exec(`INSERT INTO legacy_project_caps SELECT profile_id, path, memory FROM profile_projects WHERE memory != 'content';
62
+ ALTER TABLE profile_projects DROP COLUMN memory;`);
63
+ }
64
+ db.exec(`PRAGMA user_version = ${SCOPED_SCHEMA_VERSION}`);
65
+ }
66
+ db.exec('COMMIT');
67
+ }
68
+ catch (error) {
69
+ db.exec('ROLLBACK');
70
+ throw error;
71
+ }
72
+ }
73
+ export function assertScopedStoreReady(store) {
74
+ const db = new DatabaseSync(store.path);
75
+ try {
76
+ upgradeScopedStore(db, store);
77
+ assertVersion(db, store);
78
+ }
79
+ finally {
80
+ db.close();
81
+ }
82
+ }
83
+ export function discardIncompleteScopedStore(store) {
84
+ if (!scopedStoreExists(store))
85
+ return;
86
+ const db = new DatabaseSync(store.path, { readOnly: true });
87
+ let found;
88
+ try {
89
+ found = version(db);
90
+ }
91
+ finally {
92
+ db.close();
93
+ }
94
+ if (found === 0)
95
+ for (const path of [store.path, `${store.path}-wal`, `${store.path}-shm`])
96
+ rmSync(path, { force: true });
97
+ }
98
+ export function withProjectStateLock(root, run) {
99
+ return withExclusiveDirectoryLock(join(root, '.init.lock'), () => {
100
+ const path = join(root, STATE_FILE);
101
+ const deadline = Date.now() + 5_000;
102
+ let pollMs = 10;
103
+ for (;;) {
104
+ let release;
105
+ try {
106
+ release = lockfile.lockSync(path, { realpath: false, retries: 0, stale: 30_000, update: 10_000 });
107
+ }
108
+ catch (error) {
109
+ if (error.code !== 'ELOCKED')
110
+ throw error;
111
+ const remaining = deadline - Date.now();
112
+ if (remaining <= 0)
113
+ throw new Error(`timed out waiting for project state conversion lock: ${path}`, { cause: error });
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(pollMs, remaining));
115
+ pollMs = Math.min(pollMs * 2, 250);
116
+ continue;
117
+ }
118
+ try {
119
+ return run();
120
+ }
121
+ finally {
122
+ release();
123
+ }
124
+ }
125
+ });
126
+ }
127
+ function secure(path) {
128
+ if (existsSync(path))
129
+ chmodSync(path, 0o600);
130
+ for (const suffix of ['-wal', '-shm'])
131
+ if (existsSync(`${path}${suffix}`))
132
+ chmodSync(`${path}${suffix}`, 0o600);
133
+ }
134
+ export function scopedStoreExists(store) {
135
+ return existsSync(store.path);
136
+ }
137
+ export function legacyScopedStorePaths(store) {
138
+ if (store.kind === 'project')
139
+ return [join(store.root, STATE_FILE)];
140
+ if (store.kind === 'provider')
141
+ return [join(store.root, 'anthropic-subscriptions.json'), join(store.root, 'openai-codex-subscriptions.json'), join(store.root, 'provider-rotation.json')];
142
+ const paths = [join(store.root, CONFIG_FILE), join(store.root, STATE_FILE), join(store.root, 'profile-defaults.json')];
143
+ const profiles = join(store.root, PROFILE_DIR);
144
+ if (existsSync(profiles))
145
+ for (const entry of readdirSync(profiles, { withFileTypes: true }))
146
+ if (entry.isDirectory() && PROFILE_ID_SHAPE.test(entry.name))
147
+ paths.push(join(profiles, entry.name, 'profile.json'), join(profiles, entry.name, CONFIG_FILE), join(profiles, entry.name, STATE_FILE));
148
+ return paths;
149
+ }
150
+ export function initializeFreshScopedStore(input) {
151
+ const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
152
+ if (store.kind !== 'project')
153
+ mkdirSync(store.root, { recursive: true, mode: 0o700 });
154
+ const initialize = () => {
155
+ if (store.kind === 'project')
156
+ discardIncompleteScopedStore(store);
157
+ if (scopedStoreExists(store))
158
+ return;
159
+ const legacyPath = legacyScopedStorePaths(store).find(existsSync);
160
+ if (legacyPath !== undefined)
161
+ throw new Error(`legacy scoped state exists at ${legacyPath}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
162
+ createScopedStore(store, (db) => {
163
+ createScopedSchema(db, store.kind);
164
+ if (store.kind !== 'provider')
165
+ writeOwnerRoot(db, store.kind, store.root);
166
+ });
167
+ };
168
+ if (store.kind === 'project')
169
+ withProjectStateLock(store.root, initialize);
170
+ else
171
+ withExclusiveDirectoryLock(join(store.root, '.init.lock'), initialize);
172
+ }
173
+ export function openScopedStore(store) {
174
+ if (layoutNeedsMigration())
175
+ throw new Error('layout_migration_required');
176
+ const cached = handles.get(store.path);
177
+ if (cached !== undefined)
178
+ return cached;
179
+ initializeFreshScopedStore(store);
180
+ const db = new DatabaseSync(store.path);
181
+ try {
182
+ db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
183
+ upgradeScopedStore(db, store);
184
+ assertVersion(db, store);
185
+ secure(store.path);
186
+ handles.set(store.path, db);
187
+ return db;
188
+ }
189
+ catch (error) {
190
+ db.close();
191
+ throw error;
192
+ }
193
+ }
194
+ export function createScopedStore(store, create) {
195
+ if (existsSync(store.path))
196
+ throw new Error(`scoped SQL store already exists: ${store.path}`);
197
+ if (store.kind !== 'project')
198
+ mkdirSync(store.root, { recursive: true, mode: 0o700 });
199
+ if (!statSync(store.root).isDirectory())
200
+ throw new Error(`scoped SQL root is not a directory: ${store.root}`);
201
+ if (store.kind !== 'project' || process.platform !== 'linux')
202
+ chmodSync(store.root, 0o700);
203
+ writeFileSync(store.path, '', { mode: 0o600 });
204
+ const db = new DatabaseSync(store.path);
205
+ try {
206
+ db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; BEGIN IMMEDIATE;');
207
+ create(db);
208
+ db.exec('COMMIT');
209
+ }
210
+ catch (error) {
211
+ try {
212
+ db.exec('ROLLBACK');
213
+ }
214
+ catch { }
215
+ throw error;
216
+ }
217
+ finally {
218
+ secure(store.path);
219
+ db.close();
220
+ }
221
+ }
222
+ export function withScopedWrite(store, run) {
223
+ const db = openScopedStore(store);
224
+ if (db.isTransaction)
225
+ return run(db);
226
+ db.exec('BEGIN IMMEDIATE');
227
+ try {
228
+ const value = run(db);
229
+ db.exec('COMMIT');
230
+ secure(store.path);
231
+ return value;
232
+ }
233
+ catch (error) {
234
+ try {
235
+ db.exec('ROLLBACK');
236
+ }
237
+ catch { }
238
+ throw error;
239
+ }
240
+ }
241
+ export function closeScopedStores() {
242
+ for (const db of handles.values()) {
243
+ forgetPrepared(db);
244
+ db.close();
245
+ }
246
+ handles.clear();
247
+ }
@@ -0,0 +1,8 @@
1
+ export { initializeFreshScopedStore } from './db.js';
2
+ /** Conversion is intentionally explicit. Deployment coordination must prove all legacy writers at these shared roots are stopped before calling it. */
3
+ export declare function convertLegacyScopedStore(input: {
4
+ kind: 'user' | 'project' | 'provider';
5
+ root: string;
6
+ }): void;
7
+ /** Converts legacy provider pools only after the activation owner has stopped every writer sharing this agent directory. */
8
+ export declare function convertLegacyProviderStore(agentDir: string): Promise<void>;
@@ -0,0 +1,187 @@
1
+ import { prepared as cachedStatement } from '../sqlite-statements.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { dirname, join, relative } from 'node:path';
5
+ import lockfile from 'proper-lockfile';
6
+ import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
7
+ import { LockedJsonFile } from '../locked-file.js';
8
+ import { assertScopedStoreReady, createScopedStore, discardIncompleteScopedStore, legacyScopedStorePaths, PROFILE_ID_SHAPE, scopedStoreExists, withProjectStateLock, writeOwnerRoot } from './db.js';
9
+ import { canonicalRoot, providerStore, projectStore, userStore } from './paths.js';
10
+ import { profileSpace } from '../layout.js';
11
+ import { createScopedSchema } from './schema.js';
12
+ function json(path) { return JSON.parse(readFileSync(path, 'utf8')); }
13
+ function existsLegacy(paths) { return paths.some(existsSync); }
14
+ function preserve(root, paths) { const target = join(root, '.legacy-scoped-state'); try {
15
+ mkdirSync(target, { mode: 0o700 });
16
+ }
17
+ catch (error) {
18
+ if (error.code !== 'EEXIST' || !statSync(target).isDirectory())
19
+ throw error;
20
+ } chmodSync(target, 0o700); for (const path of paths)
21
+ if (existsSync(path) && statSync(path).isFile()) {
22
+ const fromRoot = relative(root, path);
23
+ if (fromRoot === '' || fromRoot.startsWith('..'))
24
+ throw new Error(`legacy state is outside its owning root: ${path}`);
25
+ const destination = join(target, fromRoot);
26
+ if (dirname(destination) !== target)
27
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
28
+ copyFileSync(path, destination);
29
+ chmodSync(destination, 0o600);
30
+ } }
31
+ export { initializeFreshScopedStore } from './db.js';
32
+ /** Conversion is intentionally explicit. Deployment coordination must prove all legacy writers at these shared roots are stopped before calling it. */
33
+ export function convertLegacyScopedStore(input) {
34
+ const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
35
+ if (input.kind !== 'project' && scopedStoreExists(store))
36
+ return;
37
+ if (input.kind === 'provider')
38
+ throw new Error('provider conversion requires convertLegacyProviderStore() under coordinated shared-agent-dir activation');
39
+ const paths = legacyScopedStorePaths(store);
40
+ const convert = () => {
41
+ if (input.kind === 'project')
42
+ discardIncompleteScopedStore(store);
43
+ if (scopedStoreExists(store))
44
+ return;
45
+ if (input.kind === 'project' && existsLegacy(paths))
46
+ preserve(store.root, paths);
47
+ createScopedStore(store, (db) => {
48
+ createScopedSchema(db, input.kind);
49
+ const owner = input.kind === 'user' ? 'user' : 'project';
50
+ writeOwnerRoot(db, owner, store.root);
51
+ if (input.kind === 'user')
52
+ importUser(db, store.root);
53
+ else
54
+ importState(db, owner, join(store.root, STATE_FILE));
55
+ });
56
+ if (input.kind === 'user' && existsLegacy(paths))
57
+ preserve(store.root, paths);
58
+ };
59
+ if (input.kind === 'project')
60
+ withProjectStateLock(store.root, () => { convert(); assertScopedStoreReady(store); });
61
+ else
62
+ convert();
63
+ }
64
+ function parseLegacyProviderPool(path) {
65
+ if (!existsSync(path))
66
+ return [];
67
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
68
+ if (!Array.isArray(raw))
69
+ throw new Error(`legacy subscription pool is malformed: ${path}`);
70
+ const labels = new Set();
71
+ return raw.map((item, position) => {
72
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
73
+ throw new Error(`legacy subscription pool entry ${position + 1} is malformed: ${path}`);
74
+ const value = item;
75
+ const label = typeof value.label === 'string' ? value.label.trim() : '';
76
+ const refresh = typeof value.refresh === 'string' ? value.refresh.trim() : '';
77
+ const access = typeof value.access === 'string' ? value.access.trim() : '';
78
+ const expires = Number(value.expires);
79
+ if (!label || !refresh || !access || !Number.isFinite(expires))
80
+ throw new Error(`legacy subscription pool entry ${position + 1} is incomplete: ${path}`);
81
+ const labelKey = label.toLowerCase();
82
+ if (labels.has(labelKey))
83
+ throw new Error(`legacy subscription pool has duplicate label "${label}": ${path}`);
84
+ labels.add(labelKey);
85
+ const timing = (field) => Number.isFinite(Number(value[field])) ? Number(value[field]) : 0;
86
+ const accountId = typeof value.accountId === 'string' && value.accountId.trim() !== '' ? value.accountId.trim() : undefined;
87
+ return { label, refresh, access, expires, rateLimitedUntil: timing('rateLimitedUntil'), lastAttemptAt: timing('lastAttemptAt'), lastRateLimitedAt: timing('lastRateLimitedAt'), ...(value.authFailure === 'invalid_grant' ? { authFailure: 'invalid_grant' } : {}), ...(accountId ? { accountId } : {}) };
88
+ });
89
+ }
90
+ function parseLegacyRotation(path) {
91
+ if (!existsSync(path))
92
+ return {};
93
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
94
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
95
+ throw new Error(`legacy provider rotation settings are malformed: ${path}`);
96
+ const value = raw;
97
+ return { ...(value.defaultFallbackStrength === 'ultra' || value.defaultFallbackStrength === 'strong' || value.defaultFallbackStrength === 'medium' || value.defaultFallbackStrength === 'light' ? { defaultFallbackStrength: value.defaultFallbackStrength } : {}), ...(typeof value.revertWhenAvailable === 'boolean' ? { revertWhenAvailable: value.revertWhenAvailable } : {}) };
98
+ }
99
+ async function withLegacyProviderLocks(paths, run) {
100
+ const releases = [];
101
+ try {
102
+ for (const path of paths)
103
+ if (existsSync(path))
104
+ releases.push(await lockfile.lock(path, { realpath: false, retries: 0, stale: 30_000, update: 10_000 }));
105
+ return await run();
106
+ }
107
+ finally {
108
+ await Promise.allSettled(releases.reverse().map((release) => release()));
109
+ }
110
+ }
111
+ /** Converts legacy provider pools only after the activation owner has stopped every writer sharing this agent directory. */
112
+ export async function convertLegacyProviderStore(agentDir) {
113
+ const store = providerStore(agentDir);
114
+ if (scopedStoreExists(store))
115
+ return;
116
+ const paths = legacyScopedStorePaths(store);
117
+ await withLegacyProviderLocks(paths, async () => {
118
+ if (scopedStoreExists(store))
119
+ return;
120
+ const pools = new Map(paths.slice(0, 2).map((path, index) => [index === 0 ? 'anthropic' : 'openai-codex', parseLegacyProviderPool(path)]));
121
+ const rotation = parseLegacyRotation(paths[2]);
122
+ mkdirSync(store.root, { recursive: true, mode: 0o700 });
123
+ preserve(store.root, paths);
124
+ for (const [provider, pool] of pools) {
125
+ const accounts = Object.fromEntries(pool.map((entry) => { const id = randomUUID(); return [id, { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) }]; }));
126
+ const file = new LockedJsonFile(join(store.root, `${provider}-credentials.json`), true, '{"version":1,"accounts":{}}\n');
127
+ await file.withLockAsync(async (_current, _held, publish) => { publish(`${JSON.stringify({ version: 1, accounts })}\n`); return { result: undefined }; });
128
+ }
129
+ createScopedStore(store, (db) => {
130
+ createScopedSchema(db, 'provider');
131
+ for (const [provider, pool] of pools) {
132
+ const accounts = JSON.parse(readFileSync(join(store.root, `${provider}-credentials.json`), 'utf8'));
133
+ const ids = Object.keys(accounts.accounts);
134
+ const insert = cachedStatement(db, 'INSERT INTO managed_accounts(provider_id,id,label,label_key,position,last_attempt_at,last_rate_limited_at,rate_limited_until,auth_failure) VALUES(?,?,?,?,?,?,?,?,?)');
135
+ pool.forEach((entry, position) => insert.run(provider, ids[position], entry.label, entry.label.toLowerCase(), position, entry.lastAttemptAt, entry.lastRateLimitedAt, entry.rateLimitedUntil, entry.authFailure ?? null));
136
+ }
137
+ if (rotation.defaultFallbackStrength !== undefined || rotation.revertWhenAvailable !== undefined)
138
+ cachedStatement(db, 'INSERT INTO rotation_settings(singleton,default_fallback_strength,revert_when_available) VALUES(1,?,?)').run(rotation.defaultFallbackStrength ?? null, rotation.revertWhenAvailable === undefined ? null : rotation.revertWhenAvailable ? 1 : 0);
139
+ });
140
+ });
141
+ }
142
+ function importSettings(db, owner, path) { if (!existsSync(path))
143
+ return; const raw = json(path); if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
144
+ throw new Error(`legacy config is malformed: ${path}`); cachedStatement(db, 'INSERT INTO scope_settings(owner_key,raw_json) VALUES(?,?)').run(owner, JSON.stringify(raw)); }
145
+ function importState(db, owner, path) { if (!existsSync(path))
146
+ return; const raw = json(path); if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
147
+ throw new Error(`legacy state is malformed: ${path}`); const state = raw; cachedStatement(db, 'INSERT INTO scope_bookkeeping(owner_key,last_self_check,active_canvas) VALUES(?,?,?)').run(owner, state.last_self_check ?? null, state.activeCanvas ?? null); const market = cachedStatement(db, 'INSERT INTO scope_marketplaces(owner_key,name,last_updated) VALUES(?,?,?)'); for (const [name, value] of Object.entries(state.marketplaces ?? {}))
148
+ market.run(owner, name, value.last_updated ?? null); const plugins = cachedStatement(db, 'INSERT INTO scope_plugins(owner_key,name,last_updated,etag,checked_at) VALUES(?,?,?,?,?)'); for (const [name, value] of Object.entries(state.plugins ?? {}))
149
+ plugins.run(owner, name, value.last_updated ?? null, value.etag ?? null, value.checked_at ?? null); }
150
+ function importUser(db, root) {
151
+ importSettings(db, 'user', join(root, CONFIG_FILE));
152
+ importState(db, 'user', join(root, STATE_FILE));
153
+ const profiles = join(root, PROFILE_DIR);
154
+ if (existsSync(profiles))
155
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
156
+ if (!entry.isDirectory() || !PROFILE_ID_SHAPE.test(entry.name))
157
+ continue;
158
+ const id = entry.name;
159
+ const profileRoot = join(profiles, id);
160
+ const path = join(profileRoot, 'profile.json');
161
+ if (!existsSync(path))
162
+ throw new Error(`legacy profile manifest is missing: ${path}`);
163
+ const manifest = json(path);
164
+ if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.projects) || typeof manifest.name !== 'string' || typeof manifest.created_at !== 'string')
165
+ throw new Error(`legacy profile manifest is malformed: ${path}`);
166
+ cachedStatement(db, 'INSERT INTO profiles(id,name,created_at,last_used_at,paused_at,default_kind,grantee,metadata_json) VALUES(?,?,?,?,?,?,?,?)').run(id, manifest.name, manifest.created_at, manifest.last_used_at ?? null, manifest.paused_at ?? null, manifest.default_kind ?? null, manifest.grantee ?? null, JSON.stringify(manifest.metadata ?? {}));
167
+ cachedStatement(db, 'INSERT INTO scope_owners(owner_key,kind,root,profile_id) VALUES(?,?,?,?)').run(`profile:${id}`, 'profile', canonicalRoot(profileSpace(manifest.grantee ?? 'app:terminal', id).memory), id);
168
+ const project = cachedStatement(db, 'INSERT INTO profile_projects(profile_id,path,position) VALUES(?,?,?)');
169
+ const cap = cachedStatement(db, 'INSERT INTO legacy_project_caps(profile_id,path,memory) VALUES(?,?,?)');
170
+ manifest.projects.forEach((p, position) => { project.run(id, p.path, position); if (p.memory !== undefined && p.memory !== 'content')
171
+ cap.run(id, p.path, p.memory); });
172
+ importSettings(db, `profile:${id}`, join(profileRoot, CONFIG_FILE));
173
+ importState(db, `profile:${id}`, join(profileRoot, STATE_FILE));
174
+ }
175
+ const defaults = join(root, 'profile-defaults.json');
176
+ if (existsSync(defaults)) {
177
+ const raw = json(defaults);
178
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
179
+ throw new Error(`legacy profile defaults are malformed: ${defaults}`);
180
+ const insert = cachedStatement(db, 'INSERT INTO profile_defaults(directory,profile_id) VALUES(?,?)');
181
+ for (const [directory, id] of Object.entries(raw)) {
182
+ if (typeof id !== 'string')
183
+ throw new Error(`legacy profile default is malformed: ${defaults}`);
184
+ insert.run(directory, id);
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,9 @@
1
+ export type ScopedStore = {
2
+ path: string;
3
+ root: string;
4
+ kind: 'user' | 'project' | 'provider';
5
+ };
6
+ export declare function canonicalRoot(root: string): string;
7
+ export declare function userStore(root: string): ScopedStore;
8
+ export declare function projectStore(root: string): ScopedStore;
9
+ export declare function providerStore(agentDir: string): ScopedStore;
@@ -0,0 +1,27 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ export function canonicalRoot(root) {
4
+ const absolute = resolve(root);
5
+ let existing = absolute;
6
+ const missing = [];
7
+ while (!existsSync(existing)) {
8
+ const parent = dirname(existing);
9
+ if (parent === existing)
10
+ return absolute;
11
+ missing.unshift(basename(existing));
12
+ existing = parent;
13
+ }
14
+ return resolve(realpathSync(existing), ...missing);
15
+ }
16
+ export function userStore(root) {
17
+ const canonical = canonicalRoot(root);
18
+ return { path: join(canonical, 'operations.db'), root: canonical, kind: 'user' };
19
+ }
20
+ export function projectStore(root) {
21
+ const canonical = canonicalRoot(root);
22
+ return { path: join(canonical, 'operations.db'), root: canonical, kind: 'project' };
23
+ }
24
+ export function providerStore(agentDir) {
25
+ const canonical = canonicalRoot(agentDir);
26
+ return { path: join(canonical, 'crouter-operations.db'), root: canonical, kind: 'provider' };
27
+ }
@@ -0,0 +1,27 @@
1
+ import type { ProfileManifest } from '../../types.js';
2
+ export type StoredProfile = {
3
+ profileId: string;
4
+ manifest: ProfileManifest;
5
+ };
6
+ export declare function listStoredProfiles(root: string): StoredProfile[];
7
+ export declare function getStoredProfile(root: string, id: string): StoredProfile | null;
8
+ /** Pause gates need only this column; do not hydrate projects or JSON manifests. */
9
+ export declare function profilePaused(root: string, id: string): boolean;
10
+ export declare function pausedProfileIds(root: string): Set<string>;
11
+ export declare function insertStoredProfile(root: string, profile: StoredProfile): void;
12
+ export declare function updateStoredProfile(root: string, id: string, mutate: (current: ProfileManifest) => ProfileManifest): StoredProfile | null;
13
+ export declare function deleteStoredProfile(root: string, id: string): boolean;
14
+ export declare function rewriteLocalProfileGrantees(root: string): void;
15
+ export declare function readStoredDefaults(root: string): Record<string, string>;
16
+ export declare function mutateStoredDefaults(root: string, mutate: (current: Record<string, string>) => Record<string, string>): Record<string, string>;
17
+ /** Pre-canvas per-project memory caps (`none|name|preview`) the canvas import has not converted yet. */
18
+ export declare function listLegacyProjectCaps(root: string): Array<{
19
+ profileId: string;
20
+ path: string;
21
+ memory: string;
22
+ }>;
23
+ /** Store each converted profile's delivery limits and delete every legacy cap row, in one write. */
24
+ export declare function commitConvertedProjectCaps(root: string, limits: ReadonlyArray<{
25
+ profileId: string;
26
+ deliveryLimits: Record<string, string>;
27
+ }>): void;
@@ -0,0 +1,93 @@
1
+ import { prepared as cachedStatement } from '../sqlite-statements.js';
2
+ import { canonicalRoot, userStore } from './paths.js';
3
+ import { profileSpace } from '../layout.js';
4
+ import { openScopedStore, withScopedWrite } from './db.js';
5
+ function store(root) { return userStore(root); }
6
+ function rowToProfile(row, projects) {
7
+ return { profileId: row.id, manifest: { schema_version: 1, name: row.name, projects, paused_at: row.paused_at, ...(row.default_kind === null ? {} : { default_kind: row.default_kind }), ...(row.grantee === null ? {} : { grantee: row.grantee }), ...(row.delivery_limits_json === '{}' ? {} : { delivery_limits: JSON.parse(row.delivery_limits_json) }), ...(Object.keys(JSON.parse(row.metadata_json)).length === 0 ? {} : { metadata: JSON.parse(row.metadata_json) }), created_at: row.created_at, last_used_at: row.last_used_at } };
8
+ }
9
+ function get(db, id) {
10
+ const row = cachedStatement(db, 'SELECT id, name, created_at, last_used_at, paused_at, default_kind, grantee, metadata_json, delivery_limits_json FROM profiles WHERE id=?').get(id);
11
+ if (!row)
12
+ return null;
13
+ const projects = cachedStatement(db, 'SELECT path FROM profile_projects WHERE profile_id=? ORDER BY position').all(id);
14
+ return rowToProfile(row, projects);
15
+ }
16
+ export function listStoredProfiles(root) {
17
+ const db = openScopedStore(store(root));
18
+ const ids = cachedStatement(db, 'SELECT id FROM profiles ORDER BY rowid').all();
19
+ return ids.map(({ id }) => get(db, id)).filter(Boolean);
20
+ }
21
+ export function getStoredProfile(root, id) { return get(openScopedStore(store(root)), id); }
22
+ /** Pause gates need only this column; do not hydrate projects or JSON manifests. */
23
+ export function profilePaused(root, id) {
24
+ const row = cachedStatement(openScopedStore(store(root)), 'SELECT paused_at FROM profiles WHERE id=?').get(id);
25
+ return row?.paused_at != null;
26
+ }
27
+ export function pausedProfileIds(root) {
28
+ const rows = cachedStatement(openScopedStore(store(root)), 'SELECT id FROM profiles WHERE paused_at IS NOT NULL').all();
29
+ return new Set(rows.map((row) => row.id));
30
+ }
31
+ export function insertStoredProfile(root, profile) {
32
+ withScopedWrite(store(root), (db) => {
33
+ cachedStatement(db, 'INSERT INTO profiles(id,name,created_at,last_used_at,paused_at,default_kind,grantee,metadata_json,delivery_limits_json) VALUES(?,?,?,?,?,?,?,?,?)').run(profile.profileId, profile.manifest.name, profile.manifest.created_at, profile.manifest.last_used_at, profile.manifest.paused_at, profile.manifest.default_kind ?? null, profile.manifest.grantee ?? null, JSON.stringify(profile.manifest.metadata ?? {}), JSON.stringify(profile.manifest.delivery_limits ?? {}));
34
+ cachedStatement(db, 'INSERT INTO scope_owners(owner_key,kind,root,profile_id) VALUES(?,?,?,?)').run(`profile:${profile.profileId}`, 'profile', canonicalRoot(profileSpace(profile.manifest.grantee ?? 'app:terminal', profile.profileId).memory), profile.profileId);
35
+ const insert = cachedStatement(db, 'INSERT INTO profile_projects(profile_id,path,position) VALUES(?,?,?)');
36
+ profile.manifest.projects.forEach((project, position) => insert.run(profile.profileId, project.path, position));
37
+ });
38
+ }
39
+ export function updateStoredProfile(root, id, mutate) {
40
+ return withScopedWrite(store(root), (db) => {
41
+ const current = get(db, id);
42
+ if (!current)
43
+ return null;
44
+ const next = mutate(current.manifest);
45
+ cachedStatement(db, 'UPDATE profiles SET name=?, last_used_at=?, paused_at=?, default_kind=?, grantee=?, metadata_json=?, delivery_limits_json=? WHERE id=?').run(next.name, next.last_used_at, next.paused_at, next.default_kind ?? null, next.grantee ?? null, JSON.stringify(next.metadata ?? {}), JSON.stringify(next.delivery_limits ?? {}), id);
46
+ cachedStatement(db, 'DELETE FROM profile_projects WHERE profile_id=?').run(id);
47
+ const insert = cachedStatement(db, 'INSERT INTO profile_projects(profile_id,path,position) VALUES(?,?,?)');
48
+ next.projects.forEach((project, position) => insert.run(id, project.path, position));
49
+ return { profileId: id, manifest: next };
50
+ });
51
+ }
52
+ export function deleteStoredProfile(root, id) {
53
+ return withScopedWrite(store(root), (db) => {
54
+ if (!get(db, id))
55
+ return false;
56
+ const owner = `profile:${id}`;
57
+ cachedStatement(db, 'DELETE FROM scope_settings WHERE owner_key=?').run(owner);
58
+ cachedStatement(db, 'DELETE FROM scope_bookkeeping WHERE owner_key=?').run(owner);
59
+ cachedStatement(db, 'DELETE FROM scope_marketplaces WHERE owner_key=?').run(owner);
60
+ cachedStatement(db, 'DELETE FROM scope_plugins WHERE owner_key=?').run(owner);
61
+ cachedStatement(db, 'DELETE FROM scope_owners WHERE owner_key=?').run(owner);
62
+ cachedStatement(db, 'DELETE FROM profiles WHERE id=?').run(id);
63
+ return true;
64
+ });
65
+ }
66
+ export function rewriteLocalProfileGrantees(root) {
67
+ withScopedWrite(store(root), (db) => {
68
+ cachedStatement(db, "UPDATE profiles SET grantee = 'app:terminal' WHERE grantee = 'user:local'").run();
69
+ });
70
+ }
71
+ export function readStoredDefaults(root) {
72
+ const db = openScopedStore(store(root));
73
+ return Object.fromEntries(cachedStatement(db, 'SELECT directory,profile_id FROM profile_defaults').all().map((row) => [row.directory, row.profile_id]));
74
+ }
75
+ export function mutateStoredDefaults(root, mutate) {
76
+ return withScopedWrite(store(root), (db) => { const current = Object.fromEntries(cachedStatement(db, 'SELECT directory,profile_id FROM profile_defaults').all().map((row) => [row.directory, row.profile_id])); const next = mutate(current); cachedStatement(db, 'DELETE FROM profile_defaults').run(); const insert = cachedStatement(db, 'INSERT INTO profile_defaults(directory,profile_id) VALUES(?,?)'); for (const [directory, id] of Object.entries(next))
77
+ insert.run(directory, id); return next; });
78
+ }
79
+ /** Pre-canvas per-project memory caps (`none|name|preview`) the canvas import has not converted yet. */
80
+ export function listLegacyProjectCaps(root) {
81
+ return cachedStatement(openScopedStore(store(root)), 'SELECT profile_id, path, memory FROM legacy_project_caps ORDER BY profile_id, path').all()
82
+ .map((r) => ({ profileId: r.profile_id, path: r.path, memory: r.memory }));
83
+ }
84
+ /** Store each converted profile's delivery limits and delete every legacy cap row, in one write. */
85
+ export function commitConvertedProjectCaps(root, limits) {
86
+ withScopedWrite(store(root), (db) => {
87
+ const set = cachedStatement(db, 'UPDATE profiles SET delivery_limits_json=? WHERE id=?');
88
+ for (const l of limits)
89
+ if (set.run(JSON.stringify(l.deliveryLimits), l.profileId).changes !== 1)
90
+ throw new Error(`no stored profile ${l.profileId}`);
91
+ cachedStatement(db, 'DELETE FROM legacy_project_caps').run();
92
+ });
93
+ }
@@ -0,0 +1,24 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export type ProviderRow = {
3
+ providerId: string;
4
+ id: string;
5
+ label: string;
6
+ labelKey: string;
7
+ position: number;
8
+ lastAttemptAt: number;
9
+ lastRateLimitedAt: number;
10
+ rateLimitedUntil: number;
11
+ authFailure?: 'invalid_grant';
12
+ };
13
+ /** One model family's cooldown on one account (`managed_account_model_cooldowns`). */
14
+ export type ProviderModelCooldown = {
15
+ family: string;
16
+ rateLimitedUntil: number;
17
+ lastRateLimitedAt: number;
18
+ };
19
+ export declare function providerRows(agentDir: string, providerId: string): ProviderRow[];
20
+ export declare function providerRowByReference(db: DatabaseSync, providerId: string, ref: string): ProviderRow | undefined;
21
+ export declare function providerModelCooldowns(db: DatabaseSync, providerId: string): Map<string, ProviderModelCooldown[]>;
22
+ export declare function providerModelCooldownsFor(agentDir: string, providerId: string): Map<string, ProviderModelCooldown[]>;
23
+ export declare function providerWrite<T>(agentDir: string, run: (db: DatabaseSync) => T): T;
24
+ export declare function providerRevision(agentDir: string): number;
@@ -0,0 +1,19 @@
1
+ import { prepared as cachedStatement } from '../sqlite-statements.js';
2
+ import { openScopedStore, withScopedWrite } from './db.js';
3
+ import { providerStore } from './paths.js';
4
+ function map(row) { return { providerId: row.provider_id, id: row.id, label: row.label, labelKey: row.label_key, position: row.position, lastAttemptAt: row.last_attempt_at, lastRateLimitedAt: row.last_rate_limited_at, rateLimitedUntil: row.rate_limited_until, ...(row.auth_failure ? { authFailure: row.auth_failure } : {}) }; }
5
+ const ROW_COLUMNS = 'provider_id,id,label,label_key,position,last_attempt_at,last_rate_limited_at,rate_limited_until,auth_failure';
6
+ export function providerRows(agentDir, providerId) { const db = openScopedStore(providerStore(agentDir)); return db.prepare(`SELECT ${ROW_COLUMNS} FROM managed_accounts WHERE provider_id=? ORDER BY position`).all(providerId).map(map); }
7
+ export function providerRowByReference(db, providerId, ref) { const needle = ref.trim().toLowerCase(); const position = Number.parseInt(needle, 10); const row = (Number.isFinite(position) && String(position) === needle && position >= 1 ? db.prepare(`SELECT ${ROW_COLUMNS} FROM managed_accounts WHERE provider_id=? AND position=?`).get(providerId, position - 1) : db.prepare(`SELECT ${ROW_COLUMNS} FROM managed_accounts WHERE provider_id=? AND label_key=?`).get(providerId, needle)); return row ? map(row) : undefined; }
8
+ export function providerModelCooldowns(db, providerId) {
9
+ const out = new Map();
10
+ for (const row of cachedStatement(db, 'SELECT account_id,family,rate_limited_until,last_rate_limited_at FROM managed_account_model_cooldowns WHERE provider_id=? ORDER BY family').all(providerId)) {
11
+ const list = out.get(row.account_id) ?? [];
12
+ list.push({ family: row.family, rateLimitedUntil: row.rate_limited_until, lastRateLimitedAt: row.last_rate_limited_at });
13
+ out.set(row.account_id, list);
14
+ }
15
+ return out;
16
+ }
17
+ export function providerModelCooldownsFor(agentDir, providerId) { return providerModelCooldowns(openScopedStore(providerStore(agentDir)), providerId); }
18
+ export function providerWrite(agentDir, run) { return withScopedWrite(providerStore(agentDir), (db) => { const value = run(db); cachedStatement(db, 'UPDATE provider_revision SET revision=revision+1 WHERE singleton=1').run(); return value; }); }
19
+ export function providerRevision(agentDir) { const db = openScopedStore(providerStore(agentDir)); return cachedStatement(db, 'SELECT revision FROM provider_revision WHERE singleton=1').get().revision; }