@teammy/cli 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,14 +1,15 @@
1
- // apps/client/control.ts
2
- import { setTimeout as setTimeout2 } from "node:timers/promises";
1
+ // apps/client/main.ts
2
+ import { parseArgs as parseArgs4 } from "node:util";
3
+ import { resolve as resolve8 } from "node:path";
3
4
 
4
- // packages/protocol/devices.ts
5
+ // packages/protocol/project.ts
5
6
  import { z as z3 } from "zod";
6
7
 
7
8
  // packages/protocol/identity.ts
8
9
  import { z } from "zod";
9
10
  var NameSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
10
11
  var DeviceIdSchema = z.uuid();
11
- var BindRequestSchema = z.object({ deviceId: DeviceIdSchema }).strict();
12
+ var BindRequestSchema = z.object({ deviceId: DeviceIdSchema, projectId: NameSchema.optional() }).strict();
12
13
  var BindingSchema = z.object({
13
14
  workspaceId: NameSchema,
14
15
  memberId: NameSchema,
@@ -82,187 +83,198 @@ var FileTreeSchema = z2.object({
82
83
  });
83
84
  var FileSelectionSchema = z2.object({ revision: RevisionSchema, file: FileEntrySchema });
84
85
 
85
- // packages/protocol/devices.ts
86
- var CredentialSchema = z3.string().regex(/^tmy_[a-f0-9]{64}$/);
87
- var EnrollmentSchema = z3.object({
88
- operationId: z3.uuid(),
89
- memberId: NameSchema,
90
- deviceId: DeviceIdSchema,
91
- credential: CredentialSchema
92
- }).strict();
93
- var EffectivePolicySchema = z3.object({
94
- checkIntervalSeconds: z3.number().min(1).max(86400),
95
- fileSettleSeconds: z3.number().min(0).max(86400)
96
- }).strict();
97
- var SyncSummarySchema = z3.object({
98
- checkedAt: z3.string(),
99
- published: z3.boolean(),
100
- appliedFiles: z3.number().int().nonnegative(),
101
- pendingFiles: z3.number().int().nonnegative(),
102
- sessionSources: z3.number().int().nonnegative(),
103
- errors: z3.array(z3.string().max(1e3)).max(20)
104
- }).strict();
105
- var DeviceStatusSchema = z3.object({
106
- clientVersion: z3.string().max(64),
107
- receivedRevision: RevisionSchema.nullable(),
108
- appliedRevision: RevisionSchema.nullable(),
109
- effectivePolicy: EffectivePolicySchema,
110
- overrides: z3.array(z3.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
111
- configError: z3.string().max(2e3).nullable(),
112
- reportError: z3.string().max(2e3).nullable(),
113
- lastSync: SyncSummarySchema.nullable()
114
- }).strict();
115
- var WebDevicesSchema = z3.object({
116
- devices: z3.array(
117
- z3.object({
118
- memberId: NameSchema,
119
- bound: z3.boolean(),
120
- lastSeenAt: z3.string().nullable(),
121
- status: DeviceStatusSchema.nullable()
122
- })
123
- )
124
- });
125
-
126
86
  // packages/protocol/project.ts
127
- import { z as z4 } from "zod";
128
- var ServerUrlSchema = z4.string().url().max(2048).refine((value) => {
87
+ var ServerUrlSchema = z3.string().url().max(2048).refine((value) => {
129
88
  const url = new URL(value);
130
89
  return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
131
90
  }, "Use an HTTP(S) server URL without credentials, query or fragment.");
132
- var SyncPolicySchema = z4.object({
133
- checkIntervalSeconds: z4.number().int().min(1).max(86400),
134
- fileSettleSeconds: z4.number().int().min(0).max(86400)
91
+ var SyncPolicySchema = z3.object({
92
+ checkIntervalSeconds: z3.number().int().min(1).max(86400),
93
+ fileSettleSeconds: z3.number().int().min(0).max(86400)
135
94
  }).strict();
136
95
  var DEFAULT_POLICY = { checkIntervalSeconds: 60, fileSettleSeconds: 60 };
137
- var ProjectConfigSchema = z4.object({
138
- schemaVersion: z4.literal(1),
96
+ var ProjectConfigSchema = z3.object({
97
+ schemaVersion: z3.literal(1),
139
98
  projectId: NameSchema,
140
- projectName: z4.string().trim().min(1).max(120),
141
- description: z4.string().max(2e3),
99
+ projectName: z3.string().trim().min(1).max(120),
100
+ description: z3.string().max(2e3),
142
101
  publicServerUrl: ServerUrlSchema,
143
102
  configRevision: RevisionSchema,
144
- scopeProfile: z4.literal("workspace-and-sessions-v1"),
103
+ scopeProfile: z3.literal("workspace-and-sessions-v1"),
145
104
  sync: SyncPolicySchema
146
105
  }).strict();
147
- var SetupSchema = z4.object({
148
- operationId: z4.uuid(),
149
- projectName: z4.string().trim().min(1).max(120),
150
- description: z4.string().max(2e3).default(""),
106
+ var SetupSchema = z3.object({
107
+ operationId: z3.uuid(),
108
+ projectName: z3.string().trim().min(1).max(120),
109
+ description: z3.string().max(2e3).default(""),
151
110
  publicServerUrl: ServerUrlSchema,
152
111
  adminId: NameSchema,
153
- members: z4.array(NameSchema).max(100).default([]),
154
- existingWorkspaceId: NameSchema.optional(),
112
+ members: z3.array(NameSchema).max(100).default([]),
155
113
  sync: SyncPolicySchema.default(DEFAULT_POLICY)
156
114
  }).strict();
157
- var SetupStatusSchema = z4.object({
158
- initialized: z4.boolean(),
159
- existingWorkspaces: z4.array(NameSchema)
115
+ var SetupStatusSchema = z3.object({
116
+ initialized: z3.boolean()
160
117
  });
161
118
  var ConfigUpdateSchema = ProjectConfigSchema.pick({
162
119
  projectName: true,
163
- description: true,
164
- publicServerUrl: true,
165
- sync: true
166
- }).extend({ expectedRevision: RevisionSchema }).strict();
167
- var MemberSchema = z4.object({
120
+ description: true
121
+ }).extend({ expectedRevision: RevisionSchema.optional() }).strict();
122
+ var MemberSchema = z3.object({
168
123
  id: NameSchema,
169
- displayName: z4.string().max(120),
170
- role: z4.enum(["admin", "member"]),
171
- enabled: z4.boolean(),
124
+ displayName: z3.string().max(120),
125
+ enabled: z3.boolean(),
126
+ accountEnabled: z3.boolean(),
172
127
  revision: RevisionSchema,
173
- bound: z4.boolean(),
128
+ bound: z3.boolean(),
174
129
  fileRevision: RevisionSchema,
175
- lastPublishedAt: z4.string().nullable()
130
+ lastPublishedAt: z3.string().nullable()
176
131
  });
177
- var WebSessionSchema = z4.object({
178
- workspaceId: NameSchema,
132
+ var WebIdentitySchema = z3.object({
179
133
  memberId: NameSchema,
180
- displayName: z4.string(),
181
- role: z4.enum(["admin", "member"])
134
+ displayName: z3.string(),
135
+ siteRole: z3.enum(["admin", "member"])
136
+ });
137
+ var WebSessionSchema = WebIdentitySchema.extend({
138
+ workspaceId: NameSchema
139
+ });
140
+ var MemberCreateSchema = z3.object({
141
+ id: NameSchema
142
+ }).strict();
143
+ var MemberUpdateSchema = z3.object({
144
+ expectedRevision: RevisionSchema,
145
+ displayName: z3.string().trim().max(120).optional(),
146
+ enabled: z3.boolean().optional()
147
+ }).strict();
148
+ var ProjectCreateSchema = z3.object({
149
+ operationId: z3.uuid(),
150
+ projectName: z3.string().trim().min(1).max(120),
151
+ description: z3.string().max(2e3).default("")
152
+ }).strict();
153
+ var ProjectSummarySchema = z3.object({
154
+ projectId: NameSchema,
155
+ projectName: z3.string(),
156
+ description: z3.string()
157
+ });
158
+ var ProjectsSchema = z3.object({ projects: z3.array(ProjectSummarySchema) });
159
+ var SiteConfigSchema = z3.object({
160
+ sync: SyncPolicySchema,
161
+ publicServerUrl: ServerUrlSchema,
162
+ revision: RevisionSchema
163
+ });
164
+ var SiteConfigUpdateSchema = z3.object({
165
+ publicServerUrl: ServerUrlSchema.optional(),
166
+ sync: SyncPolicySchema.optional(),
167
+ expectedRevision: RevisionSchema
168
+ }).strict().refine(
169
+ (v) => v.publicServerUrl !== void 0 || v.sync !== void 0,
170
+ "Provide a setting to update."
171
+ );
172
+ var UserSchema = z3.object({
173
+ id: NameSchema,
174
+ displayName: z3.string(),
175
+ siteRole: z3.enum(["admin", "member"]),
176
+ enabled: z3.boolean(),
177
+ revision: RevisionSchema
182
178
  });
183
- var MemberCreateSchema = z4.object({ id: NameSchema, displayName: z4.string().trim().max(120).default("") }).strict();
184
- var MemberUpdateSchema = z4.object({
179
+ var UserCreateSchema = z3.object({
180
+ id: NameSchema,
181
+ displayName: z3.string().trim().max(120).default(""),
182
+ siteRole: z3.enum(["admin", "member"]).default("member")
183
+ }).strict();
184
+ var UserUpdateSchema = z3.object({
185
185
  expectedRevision: RevisionSchema,
186
- displayName: z4.string().trim().max(120).optional(),
187
- enabled: z4.boolean().optional()
186
+ displayName: z3.string().trim().max(120).optional(),
187
+ enabled: z3.boolean().optional(),
188
+ siteRole: z3.enum(["admin", "member"]).optional()
188
189
  }).strict();
189
190
 
190
191
  // packages/protocol/sessions.ts
191
- import { z as z5 } from "zod";
192
- var AgentSchema = z5.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
193
- var SessionSnapshotSchema = z5.object({
194
- formatVersion: z5.literal(1),
195
- adapterVersion: z5.literal(1),
192
+ import { z as z4 } from "zod";
193
+ var AgentSchema = z4.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
194
+ var SessionSnapshotSchema = z4.object({
195
+ formatVersion: z4.literal(1),
196
+ adapterVersion: z4.literal(1),
196
197
  agent: AgentSchema,
197
- nativeId: z5.string().min(1).max(512),
198
- cwd: z5.string().min(1).max(4096),
199
- messages: z5.array(
200
- z5.object({
201
- id: z5.string().min(1).max(1024),
202
- role: z5.enum(["user", "assistant"]),
203
- text: z5.string().min(1).max(2 * 1024 * 1024),
204
- source: z5.string().min(1).max(1024)
198
+ nativeId: z4.string().min(1).max(512),
199
+ cwd: z4.string().min(1).max(4096),
200
+ messages: z4.array(
201
+ z4.object({
202
+ id: z4.string().min(1).max(1024),
203
+ role: z4.enum(["user", "assistant"]),
204
+ text: z4.string().min(1).max(2 * 1024 * 1024),
205
+ source: z4.string().min(1).max(1024)
205
206
  }).strict()
206
207
  ).max(5e4)
207
208
  }).strict().refine(
208
209
  (s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
209
210
  "Duplicate message IDs."
210
211
  );
211
- var SessionCommitSchema = z5.object({
212
- operationId: z5.uuid(),
212
+ var SessionCommitSchema = z4.object({
213
+ operationId: z4.uuid(),
213
214
  sessionKey: HashSchema,
214
215
  parentRevision: RevisionSchema,
215
216
  blob: BlobRefSchema
216
217
  }).strict();
217
- var SessionInfoSchema = z5.object({
218
+ var SessionInfoSchema = z4.object({
218
219
  owner: NameSchema,
219
220
  sessionKey: HashSchema,
220
221
  agent: AgentSchema,
221
- nativeId: z5.string(),
222
- cwd: z5.string(),
223
- title: z5.string(),
222
+ nativeId: z4.string(),
223
+ cwd: z4.string(),
224
+ title: z4.string(),
224
225
  revision: RevisionSchema,
225
- updatedAt: z5.string(),
226
- messageCount: z5.number().int().nonnegative(),
226
+ updatedAt: z4.string(),
227
+ messageCount: z4.number().int().nonnegative(),
227
228
  blob: BlobRefSchema
228
229
  });
229
- var SessionListSchema = z5.object({
230
- sessions: z5.array(SessionInfoSchema),
230
+ var SessionListSchema = z4.object({
231
+ sessions: z4.array(SessionInfoSchema),
231
232
  nextCursor: RevisionSchema.nullable()
232
233
  });
233
- var SessionSearchSchema = z5.object({
234
- matches: z5.array(
235
- z5.object({
234
+ var SessionStatsSchema = z4.object({
235
+ totalSessions: z4.number().int().nonnegative(),
236
+ totalMessages: z4.number().int().nonnegative(),
237
+ memberCount: z4.number().int().nonnegative(),
238
+ updatedAt: z4.string().nullable()
239
+ });
240
+ var SessionScopeSchema = z4.object({
241
+ owner: NameSchema.optional(),
242
+ sessionKey: HashSchema.optional()
243
+ }).strict().refine((v) => !v.sessionKey || !!v.owner, "A session scope requires an owner.");
244
+ var SessionSearchSchema = z4.object({
245
+ matches: z4.array(
246
+ z4.object({
236
247
  owner: NameSchema,
237
248
  sessionKey: HashSchema,
238
249
  revision: RevisionSchema,
239
- messageId: z5.string(),
240
- role: z5.enum(["user", "assistant"]),
241
- snippet: z5.string()
250
+ messageId: z4.string(),
251
+ role: z4.enum(["user", "assistant"]),
252
+ snippet: z4.string(),
253
+ title: z4.string()
242
254
  })
243
255
  ),
244
256
  nextCursor: RevisionSchema.nullable()
245
257
  });
246
258
 
247
259
  // packages/transport/hub-client.ts
248
- import { z as z7 } from "zod";
260
+ import { z as z6 } from "zod";
249
261
  import { createHash } from "node:crypto";
250
262
 
251
263
  // packages/protocol/index.ts
252
- import { z as z6 } from "zod";
264
+ import { z as z5 } from "zod";
253
265
  var PROTOCOL_VERSION = 1;
254
- var HealthSchema = z6.object({ status: z6.literal("ok") });
255
- var ServerInfoSchema = z6.object({
256
- name: z6.literal("teammy"),
257
- version: z6.string().min(1),
258
- protocolVersion: z6.literal(PROTOCOL_VERSION),
259
- capabilities: z6.array(z6.string())
266
+ var HealthSchema = z5.object({ status: z5.literal("ok") });
267
+ var ServerInfoSchema = z5.object({
268
+ name: z5.literal("teammy"),
269
+ version: z5.string().min(1),
270
+ protocolVersion: z5.literal(PROTOCOL_VERSION),
271
+ capabilities: z5.array(z5.string())
260
272
  });
261
- var ErrorResponseSchema = z6.object({
262
- error: z6.object({
263
- code: z6.string(),
264
- message: z6.string(),
265
- requestId: z6.string()
273
+ var ErrorResponseSchema = z5.object({
274
+ error: z5.object({
275
+ code: z5.string(),
276
+ message: z5.string(),
277
+ requestId: z5.string()
266
278
  })
267
279
  });
268
280
 
@@ -306,8 +318,11 @@ var HubClient = class {
306
318
  get url() {
307
319
  return this.baseUrl.toString();
308
320
  }
309
- bind(deviceId) {
310
- return this.request("POST", "api/v1/bind", BindingSchema, { deviceId });
321
+ bind(deviceId, projectId) {
322
+ return this.request("POST", "api/v1/bind", BindingSchema, {
323
+ deviceId,
324
+ ...projectId ? { projectId } : {}
325
+ });
311
326
  }
312
327
  members() {
313
328
  return this.request("GET", "api/v1/members", MembersSchema);
@@ -325,18 +340,18 @@ var HubClient = class {
325
340
  "GET",
326
341
  `api/v1/blobs/${HashSchema.parse(hash2)}`
327
342
  );
328
- if (!response.ok) return this.decode(response, signal, z7.never());
343
+ if (!response.ok) return this.decode(response, signal, z6.never());
329
344
  const reader = response.body?.getReader();
330
345
  if (!reader) throw new Error("Server returned no content body.");
331
346
  const parts = [];
332
347
  let size = 0;
333
348
  try {
334
349
  for (; ; ) {
335
- const result = await reader.read();
336
- if (result.done) break;
337
- size += result.value.byteLength;
350
+ const result2 = await reader.read();
351
+ if (result2.done) break;
352
+ size += result2.value.byteLength;
338
353
  if (size > MAX_BLOB_BYTES) throw new Error("Downloaded content exceeds the size limit.");
339
- parts.push(result.value);
354
+ parts.push(result2.value);
340
355
  }
341
356
  } finally {
342
357
  await reader.cancel();
@@ -370,7 +385,7 @@ var HubClient = class {
370
385
  return this.request(
371
386
  "PUT",
372
387
  "api/v1/devices/self/status",
373
- z7.object({ ok: z7.literal(true) }),
388
+ z6.object({ ok: z6.literal(true) }),
374
389
  status
375
390
  );
376
391
  }
@@ -414,6 +429,8 @@ var HubClient = class {
414
429
  return this.decode(response, signal, schema);
415
430
  }
416
431
  async fetchResponse(method, path, requestBody) {
432
+ if (this.credentials?.workspaceId && path.startsWith("api/v1/") && !["api/v1/bind", "api/v1/server"].includes(path))
433
+ path = `api/v1/projects/${encodeURIComponent(this.credentials.workspaceId)}/${path.slice("api/v1/".length)}`;
417
434
  const timeout = AbortSignal.timeout(this.timeoutMs);
418
435
  const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
419
436
  const binary = requestBody instanceof Uint8Array;
@@ -464,64 +481,107 @@ var HubClient = class {
464
481
  }
465
482
  };
466
483
 
467
- // apps/client/project-state.ts
468
- import { randomBytes, randomUUID } from "node:crypto";
469
- var projectClientMigration = String.raw`
470
- CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
471
- CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
484
+ // apps/client/sessions/command.ts
485
+ import { createHash as createHash6 } from "node:crypto";
486
+ import { realpath as realpath4 } from "node:fs/promises";
487
+ import { homedir } from "node:os";
488
+ import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
489
+ import { parseArgs } from "node:util";
490
+
491
+ // apps/client/runtime/state.ts
492
+ var runtimeMigration = `
493
+ ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
494
+ UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
495
+ CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
496
+ CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
472
497
  `;
473
- var ClientProjectState = class {
474
- constructor(state, root) {
498
+ var RuntimeState = class {
499
+ constructor(state) {
475
500
  this.state = state;
476
- this.root = root;
477
501
  }
478
502
  state;
479
- root;
480
- enrollment(url, memberId, reconnect = false) {
481
- const deviceId = this.state.deviceFor(this.root);
482
- const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
483
- if (row && !reconnect) {
484
- const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
485
- if (row.server_url !== url || request2.memberId !== memberId)
486
- throw new Error("Another enrollment is pending for this root; use the same member/server.");
487
- return request2;
488
- }
489
- const request = {
490
- operationId: randomUUID(),
491
- memberId,
492
- deviceId,
493
- credential: `tmy_${randomBytes(32).toString("hex")}`
494
- };
503
+ enabled(root) {
504
+ return Boolean(
505
+ this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
506
+ );
507
+ }
508
+ enable(root, enabled) {
509
+ if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
495
510
  this.state.db.prepare(
496
- "INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
497
- ).run(this.root, url, JSON.stringify(request));
498
- return request;
511
+ "INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
512
+ ).run(root, Number(enabled));
499
513
  }
500
- completeEnrollment() {
501
- this.state.db.prepare("DELETE FROM client_enrollments WHERE root=?").run(this.root);
514
+ roots() {
515
+ return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
502
516
  }
503
- cached() {
504
- const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
505
- return row ? {
506
- config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
507
- hash: String(row.descriptor_hash)
508
- } : void 0;
517
+ status(root) {
518
+ const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
519
+ return row?.status_json ? JSON.parse(String(row.status_json)) : null;
509
520
  }
510
- save(config, hash2) {
521
+ save(root, status) {
511
522
  this.state.db.prepare(
512
- "INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
513
- ).run(this.root, JSON.stringify(config), hash2);
523
+ "INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
524
+ ).run(root, JSON.stringify(status));
514
525
  }
515
526
  };
516
527
 
517
- // apps/client/project-file.ts
518
- import { createHash as createHash3 } from "node:crypto";
528
+ // apps/client/state.ts
529
+ import { randomUUID as randomUUID3 } from "node:crypto";
530
+ import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
531
+ import { z as z8 } from "zod";
532
+
533
+ // packages/state/sqlite.ts
534
+ import { chmodSync, mkdirSync } from "node:fs";
535
+ import { dirname } from "node:path";
536
+ import { DatabaseSync } from "node:sqlite";
537
+ function transaction(db, action) {
538
+ db.exec("BEGIN IMMEDIATE");
539
+ try {
540
+ const result2 = action();
541
+ db.exec("COMMIT");
542
+ return result2;
543
+ } catch (error) {
544
+ db.exec("ROLLBACK");
545
+ throw error;
546
+ }
547
+ }
548
+ function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
549
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0)
550
+ throw new Error("Invalid SQLite busy timeout.");
551
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
552
+ const db = new DatabaseSync(path);
553
+ try {
554
+ chmodSync(path, 384);
555
+ db.exec(
556
+ `PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;`
557
+ );
558
+ transaction(db, () => {
559
+ const version = Number(db.prepare("PRAGMA user_version").get().user_version);
560
+ if (version > migrations.length)
561
+ throw new Error("Database was created by a newer Teammy version.");
562
+ for (let index = version; index < migrations.length; index++) {
563
+ db.exec(migrations[index]);
564
+ db.exec(`PRAGMA user_version = ${index + 1}`);
565
+ }
566
+ });
567
+ return db;
568
+ } catch (error) {
569
+ db.close();
570
+ throw error;
571
+ }
572
+ }
573
+
574
+ // apps/client/workspace.ts
575
+ import { execFile } from "node:child_process";
576
+ import { promisify } from "node:util";
577
+ import { lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rmdir as rmdir2 } from "node:fs/promises";
578
+ import { basename, dirname as dirname3, join as join2, resolve } from "node:path";
519
579
 
520
580
  // apps/client/files/local-fs.ts
521
- import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
581
+ import { createHash as createHash2, randomUUID } from "node:crypto";
522
582
  import { constants } from "node:fs";
523
583
  import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
524
- import { dirname, join, relative, sep } from "node:path";
584
+ import { dirname as dirname2, join, relative, sep } from "node:path";
525
585
  import { setImmediate } from "node:timers/promises";
526
586
  function missing(error) {
527
587
  return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
@@ -651,7 +711,7 @@ async function syncDirectory(directory) {
651
711
  }
652
712
  async function replaceFile(root, path, bytes, expected) {
653
713
  const target = await safePath(root, path, true);
654
- const temporary = join(dirname(target), `.teammy-${randomUUID2()}.tmp`);
714
+ const temporary = join(dirname2(target), `.teammy-${randomUUID()}.tmp`);
655
715
  const file = await open(temporary, "wx", 384);
656
716
  try {
657
717
  try {
@@ -668,11 +728,11 @@ async function replaceFile(root, path, bytes, expected) {
668
728
  throw new Error(`Local file changed before replacement: ${path}`);
669
729
  await safePath(root, path);
670
730
  await rename(temporary, target);
671
- let directory = dirname(target);
731
+ let directory = dirname2(target);
672
732
  for (; ; ) {
673
733
  await syncDirectory(directory);
674
- if (directory === dirname(root)) break;
675
- directory = dirname(directory);
734
+ if (directory === dirname2(root)) break;
735
+ directory = dirname2(directory);
676
736
  }
677
737
  } finally {
678
738
  await unlink(temporary).catch((error) => {
@@ -687,65 +747,351 @@ async function removeFile(root, path, expected) {
687
747
  throw new Error(`Local file changed before deletion: ${path}`);
688
748
  const target = await safePath(root, path);
689
749
  await unlink(target);
690
- await syncDirectory(dirname(target));
691
- let parent = dirname(target);
750
+ await syncDirectory(dirname2(target));
751
+ let parent = dirname2(target);
692
752
  while (parent !== root) {
693
753
  try {
694
754
  await rmdir(parent);
695
- await syncDirectory(dirname(parent));
755
+ await syncDirectory(dirname2(parent));
696
756
  } catch {
697
757
  break;
698
758
  }
699
- parent = dirname(parent);
759
+ parent = dirname2(parent);
700
760
  }
701
761
  }
702
762
 
703
- // apps/client/project-file.ts
704
- var name = ".teammy.json";
705
- async function applyProjectFile(local, input) {
706
- const config = ProjectConfigSchema.parse(input);
707
- const binding = local.state.binding(local.root);
708
- if (!binding || binding.workspaceId !== config.projectId)
709
- throw new Error("Project configuration does not match the local binding.");
710
- const cached = local.cached();
711
- if (cached && config.configRevision < cached.config.configRevision)
712
- throw new Error("Server project configuration moved backwards.");
713
- const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
714
- const hash2 = createHash3("sha256").update(bytes).digest("hex");
715
- const entry = await metadata(local.root, name);
716
- let signature2 = null;
717
- if (entry) {
718
- if (entry.directory)
719
- throw new Error("Project configuration path is a directory; preserving it.");
720
- const existing = await readStable(local.root, name);
721
- signature2 = existing.signature;
722
- if (existing.sha256 !== hash2 && existing.sha256 !== cached?.hash)
763
+ // apps/client/workspace.ts
764
+ var exec = promisify(execFile);
765
+ async function workspacePath(input) {
766
+ const path = resolve(input);
767
+ return join2(await realpath2(dirname3(path)), basename(path));
768
+ }
769
+ async function directoryIdentity(root) {
770
+ const stat2 = await lstat2(root, { bigint: true });
771
+ if (!stat2.isDirectory() || stat2.isSymbolicLink())
772
+ throw new Error("Workspace must be an ordinary directory.");
773
+ return `${stat2.dev}:${stat2.ino}`;
774
+ }
775
+ async function verifyWorkspace(state, root) {
776
+ const identity = await directoryIdentity(root);
777
+ const prior = state.db.prepare("SELECT identity FROM client_preparations WHERE root=?").get(root);
778
+ if (prior && prior.identity !== identity)
779
+ throw new Error(
780
+ "Workspace directory was replaced. Restore the original directory before syncing."
781
+ );
782
+ }
783
+ async function ignorePlan(root) {
784
+ const parent = dirname3(root), name2 = basename(root);
785
+ if (/[\r\n]/.test(name2)) throw new Error("Workspace name cannot contain a newline.");
786
+ let repo = false;
787
+ for (let p = parent; ; p = dirname3(p)) {
788
+ try {
789
+ await lstat2(join2(p, ".git"));
790
+ repo = true;
791
+ break;
792
+ } catch (e) {
793
+ if (!missing(e)) throw e;
794
+ }
795
+ if (p === dirname3(p)) break;
796
+ }
797
+ if (repo) {
798
+ let tracked;
799
+ try {
800
+ tracked = (await exec("git", ["-C", parent, "--literal-pathspecs", "ls-files", "-z", "--", name2], {
801
+ maxBuffer: 1024 * 1024
802
+ })).stdout;
803
+ } catch {
804
+ throw new Error("Cannot inspect the Git index. Check Git access before connecting.");
805
+ }
806
+ if (tracked)
723
807
  throw new Error(
724
- "Existing .teammy.json is not the expected managed copy. Preserve it elsewhere before retrying."
808
+ "Git already tracks this workspace path. Remove it from the Git index yourself before connecting."
725
809
  );
726
- if (existing.sha256 === hash2) {
727
- local.save(config, hash2);
810
+ try {
811
+ await exec("git", ["-C", parent, "check-ignore", "--no-index", "-q", "--", name2 + "/"]);
728
812
  return;
813
+ } catch (e) {
814
+ if (e.code !== 1) throw new Error("Cannot check Git ignore rules.");
729
815
  }
730
816
  }
731
- await replaceFile(local.root, name, bytes, signature2);
732
- local.save(config, hash2);
817
+ const entry = await metadata(parent, ".gitignore");
818
+ if (entry?.directory) throw new Error(".gitignore is a directory.");
819
+ const original = entry ? await readStable(parent, ".gitignore") : null;
820
+ const content = original ? new TextDecoder("utf-8", { fatal: true }).decode(original.bytes) : "";
821
+ const rule = "/" + name2.replace(/[\\*?[\]#! ]/g, "\\$&") + "/";
822
+ if (content.split(/\r?\n/).includes(rule)) return;
823
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
824
+ return {
825
+ parent,
826
+ bytes: Buffer.from(
827
+ content + (content && !content.endsWith("\n") ? newline : "") + rule + newline
828
+ ),
829
+ signature: original?.signature ?? null
830
+ };
831
+ }
832
+ async function prepareWorkspace(state, root, input, reconnect = false) {
833
+ state.assertRootAvailable(root);
834
+ const prior = state.db.prepare("SELECT * FROM client_preparations WHERE root=?").get(root);
835
+ let present = false;
836
+ try {
837
+ await lstat2(root);
838
+ present = true;
839
+ } catch (e) {
840
+ if (!missing(e)) throw e;
841
+ }
842
+ if (present) {
843
+ if (reconnect && state.binding(root)) {
844
+ await verifyWorkspace(state, root);
845
+ return;
846
+ }
847
+ if (prior && !prior.complete && prior.input_json === JSON.stringify(input)) {
848
+ await verifyWorkspace(state, root);
849
+ const ignore2 = await ignorePlan(root);
850
+ if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
851
+ return;
852
+ }
853
+ throw new Error(
854
+ 'Workspace path already exists. Choose another directory; for a connected workspace use "teammy sync on".'
855
+ );
856
+ }
857
+ if (state.binding(root) || prior)
858
+ throw new Error("Connected workspace is missing. Restore its directory before syncing.");
859
+ const ignore = await ignorePlan(root);
860
+ await mkdir2(root);
861
+ try {
862
+ const identity = await directoryIdentity(root);
863
+ state.deviceFor(root);
864
+ state.db.prepare("INSERT INTO client_preparations(root,identity,input_json) VALUES (?,?,?)").run(root, identity, JSON.stringify(input));
865
+ } catch (e) {
866
+ await rmdir2(root).catch(() => {
867
+ });
868
+ throw e;
869
+ }
870
+ if (ignore) await replaceFile(ignore.parent, ".gitignore", ignore.bytes, ignore.signature);
871
+ }
872
+ function completePreparation(state, root) {
873
+ state.db.prepare("UPDATE client_preparations SET complete=1 WHERE root=?").run(root);
874
+ }
875
+
876
+ // apps/client/runtime/log.ts
877
+ import { appendFileSync, mkdirSync as mkdirSync2, renameSync, statSync, unlinkSync, readFileSync } from "node:fs";
878
+ import { join as join3 } from "node:path";
879
+ import { createHash as createHash3 } from "node:crypto";
880
+ function rootKey(root) {
881
+ return createHash3("sha256").update(root).digest("hex").slice(0, 16);
882
+ }
883
+ function redact(value) {
884
+ return value.replace(/tmy_[a-f0-9]+/gi, "[credential]").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/(https?:\/\/)[^\s/]+@/gi, "$1[redacted]@").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 1500);
733
885
  }
886
+ function diagnostic(error) {
887
+ if (!(error instanceof Error)) return "Operation failed.";
888
+ const fields2 = error;
889
+ return redact([error.message, fields2.code, fields2.requestId].filter(Boolean).join(" \xB7 "));
890
+ }
891
+ var ClientLog = class {
892
+ constructor(directory, limit = 1024 * 1024) {
893
+ this.limit = limit;
894
+ const logs = join3(directory, "logs");
895
+ mkdirSync2(logs, { recursive: true, mode: 448 });
896
+ this.path = join3(logs, "client.jsonl");
897
+ }
898
+ limit;
899
+ path;
900
+ write(event, root, detail) {
901
+ try {
902
+ let size = 0;
903
+ try {
904
+ size = statSync(this.path).size;
905
+ } catch {
906
+ }
907
+ if (size >= this.limit) {
908
+ try {
909
+ unlinkSync(this.path + ".2");
910
+ } catch {
911
+ }
912
+ try {
913
+ renameSync(this.path + ".1", this.path + ".2");
914
+ } catch {
915
+ }
916
+ renameSync(this.path, this.path + ".1");
917
+ }
918
+ appendFileSync(
919
+ this.path,
920
+ JSON.stringify({
921
+ at: (/* @__PURE__ */ new Date()).toISOString(),
922
+ event,
923
+ root: root ? rootKey(root) : void 0,
924
+ detail: detail ? redact(detail) : void 0
925
+ }) + "\n",
926
+ { mode: 384 }
927
+ );
928
+ } catch {
929
+ }
930
+ }
931
+ tail(root, count = 20) {
932
+ const lines = [];
933
+ for (const suffix of [".2", ".1", ""])
934
+ try {
935
+ for (const line of readFileSync(this.path + suffix, "utf8").split("\n")) {
936
+ if (!line) continue;
937
+ try {
938
+ const item = JSON.parse(line);
939
+ if (!root || item.root === rootKey(root) || !item.root) lines.push(line);
940
+ } catch {
941
+ }
942
+ }
943
+ } catch {
944
+ }
945
+ return lines.slice(-count).map((line) => JSON.parse(line));
946
+ }
947
+ };
734
948
 
735
949
  // apps/client/control.ts
736
- var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
737
- var ControlPlane = class {
738
- constructor(state, root, version, overrides, apply) {
950
+ import { setTimeout as setTimeout2 } from "node:timers/promises";
951
+
952
+ // packages/protocol/devices.ts
953
+ import { z as z7 } from "zod";
954
+ var CredentialSchema = z7.string().regex(/^tmy_[a-f0-9]{64}$/);
955
+ var EnrollmentSchema = z7.object({
956
+ operationId: z7.uuid(),
957
+ projectId: NameSchema,
958
+ memberId: NameSchema,
959
+ deviceId: DeviceIdSchema,
960
+ credential: CredentialSchema
961
+ }).strict();
962
+ var EffectivePolicySchema = z7.object({
963
+ checkIntervalSeconds: z7.number().min(1).max(86400),
964
+ fileSettleSeconds: z7.number().min(0).max(86400)
965
+ }).strict();
966
+ var SyncSummarySchema = z7.object({
967
+ checkedAt: z7.string(),
968
+ published: z7.boolean(),
969
+ appliedFiles: z7.number().int().nonnegative(),
970
+ pendingFiles: z7.number().int().nonnegative(),
971
+ sessionSources: z7.number().int().nonnegative(),
972
+ errors: z7.array(z7.string().max(1e3)).max(20)
973
+ }).strict();
974
+ var DeviceStatusSchema = z7.object({
975
+ clientVersion: z7.string().max(64),
976
+ receivedRevision: RevisionSchema.nullable(),
977
+ appliedRevision: RevisionSchema.nullable(),
978
+ effectivePolicy: EffectivePolicySchema,
979
+ overrides: z7.array(z7.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
980
+ configError: z7.string().max(2e3).nullable(),
981
+ reportError: z7.string().max(2e3).nullable(),
982
+ lastSync: SyncSummarySchema.nullable()
983
+ }).strict();
984
+ var WebDevicesSchema = z7.object({
985
+ devices: z7.array(
986
+ z7.object({
987
+ memberId: NameSchema,
988
+ bound: z7.boolean(),
989
+ lastSeenAt: z7.string().nullable(),
990
+ status: DeviceStatusSchema.nullable()
991
+ })
992
+ )
993
+ });
994
+
995
+ // apps/client/project-state.ts
996
+ import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
997
+ var projectClientMigration = String.raw`
998
+ CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
999
+ CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
1000
+ `;
1001
+ var ClientProjectState = class {
1002
+ constructor(state, root) {
739
1003
  this.state = state;
740
1004
  this.root = root;
741
- this.overrides = overrides;
742
- this.apply = apply;
743
- const binding = state.binding(root);
744
- if (!binding) throw new Error("Connect this root first.");
745
- this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
746
- this.local = new ClientProjectState(state, root);
747
- const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
748
- const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
1005
+ }
1006
+ state;
1007
+ root;
1008
+ enrollment(url, memberId, projectId, reconnect = false) {
1009
+ const deviceId = this.state.deviceFor(this.root);
1010
+ const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
1011
+ if (row && !reconnect) {
1012
+ const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
1013
+ if (row.server_url !== url || request2.memberId !== memberId || request2.projectId !== projectId)
1014
+ throw new Error(
1015
+ "Another enrollment is pending for this root; use the same member/server/project."
1016
+ );
1017
+ return request2;
1018
+ }
1019
+ const request = {
1020
+ operationId: randomUUID2(),
1021
+ memberId,
1022
+ projectId,
1023
+ deviceId,
1024
+ credential: `tmy_${randomBytes(32).toString("hex")}`
1025
+ };
1026
+ this.state.db.prepare(
1027
+ "INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
1028
+ ).run(this.root, url, JSON.stringify(request));
1029
+ return request;
1030
+ }
1031
+ completeEnrollment() {
1032
+ this.state.db.prepare("DELETE FROM client_enrollments WHERE root=?").run(this.root);
1033
+ }
1034
+ cached() {
1035
+ const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
1036
+ return row ? {
1037
+ config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
1038
+ hash: String(row.descriptor_hash)
1039
+ } : void 0;
1040
+ }
1041
+ save(config, hash2) {
1042
+ this.state.db.prepare(
1043
+ "INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
1044
+ ).run(this.root, JSON.stringify(config), hash2);
1045
+ }
1046
+ };
1047
+
1048
+ // apps/client/project-file.ts
1049
+ import { createHash as createHash4 } from "node:crypto";
1050
+ var name = ".teammy.json";
1051
+ async function applyProjectFile(local, input) {
1052
+ const config = ProjectConfigSchema.parse(input);
1053
+ const binding = local.state.binding(local.root);
1054
+ if (!binding || binding.workspaceId !== config.projectId)
1055
+ throw new Error("Project configuration does not match the local binding.");
1056
+ const cached = local.cached();
1057
+ if (cached && config.configRevision < cached.config.configRevision)
1058
+ throw new Error("Server project configuration moved backwards.");
1059
+ const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
1060
+ const hash2 = createHash4("sha256").update(bytes).digest("hex");
1061
+ const entry = await metadata(local.root, name);
1062
+ let signature2 = null;
1063
+ if (entry) {
1064
+ if (entry.directory)
1065
+ throw new Error("Project configuration path is a directory; preserving it.");
1066
+ const existing = await readStable(local.root, name);
1067
+ signature2 = existing.signature;
1068
+ if (existing.sha256 !== hash2 && existing.sha256 !== cached?.hash)
1069
+ throw new Error(
1070
+ "Existing .teammy.json is not the expected managed copy. Preserve it elsewhere before retrying."
1071
+ );
1072
+ if (existing.sha256 === hash2) {
1073
+ local.save(config, hash2);
1074
+ return;
1075
+ }
1076
+ }
1077
+ await replaceFile(local.root, name, bytes, signature2);
1078
+ local.save(config, hash2);
1079
+ }
1080
+
1081
+ // apps/client/control.ts
1082
+ var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
1083
+ var ControlPlane = class {
1084
+ constructor(state, root, version, overrides, apply) {
1085
+ this.state = state;
1086
+ this.root = root;
1087
+ this.overrides = overrides;
1088
+ this.apply = apply;
1089
+ const binding = state.binding(root);
1090
+ if (!binding) throw new Error("Connect this root first.");
1091
+ this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
1092
+ this.local = new ClientProjectState(state, root);
1093
+ const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
1094
+ const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
749
1095
  const cached = this.local.cached();
750
1096
  this.status = {
751
1097
  clientVersion: version,
@@ -773,6 +1119,7 @@ var ControlPlane = class {
773
1119
  enabled = true;
774
1120
  stop = new AbortController();
775
1121
  running;
1122
+ pending;
776
1123
  persist() {
777
1124
  this.state.db.prepare(
778
1125
  "INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
@@ -782,11 +1129,19 @@ var ControlPlane = class {
782
1129
  this.status.lastSync = summary;
783
1130
  this.persist();
784
1131
  }
785
- async tick() {
1132
+ tick() {
1133
+ this.pending ??= this.check().finally(() => {
1134
+ this.pending = void 0;
1135
+ });
1136
+ return this.pending;
1137
+ }
1138
+ async check() {
786
1139
  if (this.stop.signal.aborted) return;
787
1140
  try {
1141
+ await verifyWorkspace(this.state, this.root);
788
1142
  const config = await this.client.projectConfig();
789
1143
  this.status.receivedRevision = config.configRevision;
1144
+ await verifyWorkspace(this.state, this.root);
790
1145
  await applyProjectFile(this.local, config);
791
1146
  const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
792
1147
  this.apply(effective);
@@ -798,14 +1153,14 @@ var ControlPlane = class {
798
1153
  this.enabled = false;
799
1154
  return;
800
1155
  }
801
- this.status.configError = (error instanceof Error ? error.message : "Configuration update failed.").slice(0, 2e3);
1156
+ this.status.configError = diagnostic(error);
802
1157
  }
803
1158
  this.persist();
804
1159
  this.status.reportError = null;
805
1160
  try {
806
1161
  await this.client.reportDeviceStatus(this.status);
807
1162
  } catch (error) {
808
- this.status.reportError = String(error).slice(0, 2e3);
1163
+ this.status.reportError = diagnostic(error);
809
1164
  }
810
1165
  this.persist();
811
1166
  }
@@ -818,7 +1173,7 @@ var ControlPlane = class {
818
1173
  await this.tick();
819
1174
  } catch (error) {
820
1175
  if (!this.stop.signal.aborted) {
821
- this.status.configError = String(error).slice(0, 2e3);
1176
+ this.status.configError = diagnostic(error);
822
1177
  }
823
1178
  }
824
1179
  }
@@ -827,6 +1182,7 @@ var ControlPlane = class {
827
1182
  async close() {
828
1183
  this.stop.abort();
829
1184
  await this.running;
1185
+ await this.pending;
830
1186
  }
831
1187
  };
832
1188
  function readControlStatus(state, root) {
@@ -834,143 +1190,6 @@ function readControlStatus(state, root) {
834
1190
  return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
835
1191
  }
836
1192
 
837
- // apps/client/connection.ts
838
- import { readFile, mkdir as mkdir2 } from "node:fs/promises";
839
- import { createInterface } from "node:readline/promises";
840
-
841
- // apps/client/root-lock.ts
842
- import { createHash as createHash4 } from "node:crypto";
843
- import { join as join2 } from "node:path";
844
-
845
- // packages/state/sqlite.ts
846
- import { chmodSync, mkdirSync } from "node:fs";
847
- import { dirname as dirname2 } from "node:path";
848
- import { DatabaseSync } from "node:sqlite";
849
- function transaction(db, action) {
850
- db.exec("BEGIN IMMEDIATE");
851
- try {
852
- const result = action();
853
- db.exec("COMMIT");
854
- return result;
855
- } catch (error) {
856
- db.exec("ROLLBACK");
857
- throw error;
858
- }
859
- }
860
- function openDatabase(path, migrations) {
861
- mkdirSync(dirname2(path), { recursive: true, mode: 448 });
862
- const db = new DatabaseSync(path);
863
- try {
864
- chmodSync(path, 384);
865
- db.exec(
866
- "PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"
867
- );
868
- transaction(db, () => {
869
- const version = Number(db.prepare("PRAGMA user_version").get().user_version);
870
- if (version > migrations.length)
871
- throw new Error("Database was created by a newer Teammy version.");
872
- for (let index = version; index < migrations.length; index++) {
873
- db.exec(migrations[index]);
874
- db.exec(`PRAGMA user_version = ${index + 1}`);
875
- }
876
- });
877
- return db;
878
- } catch (error) {
879
- db.close();
880
- throw error;
881
- }
882
- }
883
-
884
- // apps/client/root-lock.ts
885
- function acquireRootLock(root, stateDirectory) {
886
- let lock;
887
- try {
888
- lock = openDatabase(
889
- join2(stateDirectory, "locks", `${createHash4("sha256").update(root).digest("hex")}.sqlite3`),
890
- []
891
- );
892
- lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
893
- return lock;
894
- } catch (error) {
895
- lock?.close();
896
- if (error instanceof Error && /locked|busy/.test(error.message))
897
- throw new Error("Another sync or connect process is using this root.");
898
- throw error;
899
- }
900
- }
901
-
902
- // apps/client/connection.ts
903
- async function connectProject(state, root, directory, options) {
904
- const lock = acquireRootLock(root, directory);
905
- try {
906
- const transport = new HubClient(options.url);
907
- const existing = state.binding(root);
908
- const project = new ClientProjectState(state, root);
909
- if (existing && existing.serverUrl !== transport.url)
910
- throw new Error("This root is connected to another server.");
911
- let member = options.member;
912
- if (existing && !options.reconnect && !options.tokenFile) {
913
- if (member && member !== existing.memberId)
914
- throw new Error("This root belongs to a different member.");
915
- const client2 = new HubClient(existing.serverUrl, 1e4, existing);
916
- await client2.members();
917
- } else if (options.tokenFile) {
918
- const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
919
- const deviceId = state.deviceFor(root);
920
- const client2 = new HubClient(options.url, 1e4, { token, deviceId });
921
- const binding2 = await client2.bind(deviceId);
922
- state.saveBinding(root, client2.url, token, binding2);
923
- } else {
924
- member ??= existing?.memberId;
925
- if (!member && process.stdin.isTTY) {
926
- const rl = createInterface({ input: process.stdin, output: process.stderr });
927
- try {
928
- member = (await rl.question("Member ID: ")).trim();
929
- } finally {
930
- rl.close();
931
- }
932
- }
933
- if (!member) throw new Error("--member is required when input is not interactive.");
934
- NameSchema.parse(member);
935
- if (existing && existing.memberId !== member)
936
- throw new Error("This root belongs to a different member.");
937
- const request = project.enrollment(transport.url, member, Boolean(options.reconnect));
938
- const binding2 = await transport.enroll(request);
939
- state.saveBinding(root, transport.url, request.credential, binding2);
940
- project.completeEnrollment();
941
- }
942
- const binding = state.binding(root);
943
- const client = new HubClient(binding.serverUrl, 1e4, binding);
944
- const info = await client.serverInfo();
945
- if (info.capabilities.includes("project-config")) {
946
- try {
947
- await applyProjectFile(project, await client.projectConfig());
948
- } catch (error) {
949
- if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
950
- throw error;
951
- }
952
- }
953
- const own = await metadata(root, binding.memberId);
954
- if (own && !own.directory) throw new Error("Member path is not a directory.");
955
- if (!own) await mkdir2(await safePath(root, binding.memberId));
956
- const { token: _, ...visible } = binding;
957
- return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
958
- } finally {
959
- lock.close();
960
- }
961
- }
962
-
963
- // apps/client/main.ts
964
- import { parseArgs as parseArgs3 } from "node:util";
965
- import { mkdir as mkdir7, realpath as realpath6 } from "node:fs/promises";
966
- import { homedir as homedir3 } from "node:os";
967
- import { join as join11, resolve as resolve6 } from "node:path";
968
-
969
- // apps/client/state.ts
970
- import { randomUUID as randomUUID3 } from "node:crypto";
971
- import { join as join3 } from "node:path";
972
- import { z as z8 } from "zod";
973
-
974
1193
  // apps/client/sessions/state.ts
975
1194
  var sessionClientMigration = String.raw`
976
1195
  CREATE TABLE session_sources (
@@ -1109,7 +1328,8 @@ var SyncState = class {
1109
1328
  generation: Number(row.generation),
1110
1329
  observedAt: Number(row.observed_at),
1111
1330
  hash: row.hash === null ? null : String(row.hash),
1112
- verifiedAt: Number(row.verified_at)
1331
+ verifiedAt: Number(row.verified_at),
1332
+ seenLocal: Boolean(row.seen_local)
1113
1333
  }));
1114
1334
  }
1115
1335
  audit(before, limit) {
@@ -1127,7 +1347,8 @@ var SyncState = class {
1127
1347
  generation: Number(row.generation),
1128
1348
  observedAt: Number(row.observed_at),
1129
1349
  hash: row.hash === null ? null : String(row.hash),
1130
- verifiedAt: Number(row.verified_at)
1350
+ verifiedAt: Number(row.verified_at),
1351
+ seenLocal: Boolean(row.seen_local)
1131
1352
  } : void 0;
1132
1353
  }
1133
1354
  restartWaiting(now) {
@@ -1146,11 +1367,13 @@ var SyncState = class {
1146
1367
  const prior = this.get(path);
1147
1368
  if (!prior) {
1148
1369
  if (signature2 !== null)
1149
- this.db.prepare("INSERT INTO file_index(root,path,signature,observed_at) VALUES (?,?,?,?)").run(this.root, path, signature2, now);
1370
+ this.db.prepare(
1371
+ "INSERT INTO file_index(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)"
1372
+ ).run(this.root, path, signature2, now);
1150
1373
  } else if (signature2 !== prior.signature) {
1151
1374
  this.db.prepare(
1152
- "UPDATE file_index SET signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
1153
- ).run(signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
1375
+ "UPDATE file_index SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
1376
+ ).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
1154
1377
  } else if (prior.eventPending) {
1155
1378
  this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
1156
1379
  }
@@ -1224,7 +1447,7 @@ var LocalBindingSchema = BindingSchema.extend({
1224
1447
  var ClientState = class {
1225
1448
  db;
1226
1449
  constructor(directory) {
1227
- this.db = openDatabase(join3(directory, "client.sqlite3"), [
1450
+ this.db = openDatabase(join4(directory, "client.sqlite3"), [
1228
1451
  String.raw`
1229
1452
  CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
1230
1453
  `,
@@ -1236,13 +1459,27 @@ var ClientState = class {
1236
1459
  syncMigration,
1237
1460
  sessionClientMigration,
1238
1461
  projectClientMigration,
1239
- controlClientMigration
1462
+ controlClientMigration,
1463
+ runtimeMigration
1240
1464
  ]);
1241
1465
  }
1242
1466
  close() {
1243
1467
  this.db.close();
1244
1468
  }
1469
+ assertRootAvailable(root) {
1470
+ for (const row of this.db.prepare("SELECT root FROM connections").all()) {
1471
+ const other = String(row.root);
1472
+ if (root === other) continue;
1473
+ const contains = (parent, child) => {
1474
+ const path = relative2(parent, child);
1475
+ return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
1476
+ };
1477
+ if (contains(root, other) || contains(other, root))
1478
+ throw new Error("Sync roots must not overlap. Use separate project directories.");
1479
+ }
1480
+ }
1245
1481
  deviceFor(root) {
1482
+ this.assertRootAvailable(root);
1246
1483
  this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
1247
1484
  return String(
1248
1485
  this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
@@ -1253,6 +1490,7 @@ var ClientState = class {
1253
1490
  return row?.binding_json ? LocalBindingSchema.parse(JSON.parse(String(row.binding_json))) : void 0;
1254
1491
  }
1255
1492
  saveBinding(root, serverUrl, token, binding) {
1493
+ this.assertRootAvailable(root);
1256
1494
  const prior = this.binding(root);
1257
1495
  if (prior && (prior.workspaceId !== binding.workspaceId || prior.memberId !== binding.memberId || prior.serverUrl !== serverUrl)) {
1258
1496
  throw new Error(
@@ -1306,10 +1544,10 @@ var ClientState = class {
1306
1544
  this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
1307
1545
  });
1308
1546
  }
1309
- acknowledge(root, result) {
1547
+ acknowledge(root, result2) {
1310
1548
  transaction(this.db, () => {
1311
1549
  const pending = this.pending(root);
1312
- if (!pending || pending.batch.operationId !== result.operationId || result.revision < pending.batch.parentRevision || result.revision > pending.batch.parentRevision + 1)
1550
+ if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
1313
1551
  throw new Error("Unexpected publication acknowledgement.");
1314
1552
  for (const change of pending.batch.changes) {
1315
1553
  if (change.kind === "delete")
@@ -1319,7 +1557,7 @@ var ClientState = class {
1319
1557
  "INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
1320
1558
  ).run(root, change.path, change.sha256, change.size);
1321
1559
  }
1322
- this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result.revision, root);
1560
+ this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result2.revision, root);
1323
1561
  this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
1324
1562
  });
1325
1563
  }
@@ -1334,22 +1572,15 @@ var ClientState = class {
1334
1572
  }
1335
1573
  };
1336
1574
 
1337
- // apps/client/sessions/command.ts
1338
- import { createHash as createHash6 } from "node:crypto";
1339
- import { realpath as realpath3 } from "node:fs/promises";
1340
- import { homedir } from "node:os";
1341
- import { dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
1342
- import { parseArgs } from "node:util";
1343
-
1344
1575
  // apps/client/state-location.ts
1345
- import { mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
1346
- import { isAbsolute, relative as relative2, resolve, sep as sep2 } from "node:path";
1576
+ import { mkdir as mkdir3, realpath as realpath3 } from "node:fs/promises";
1577
+ import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve2, sep as sep3 } from "node:path";
1347
1578
  async function stateLocation(root, directory) {
1348
- await mkdir3(resolve(directory), { recursive: true, mode: 448 });
1349
- const canonical = await realpath2(directory);
1579
+ await mkdir3(resolve2(directory), { recursive: true, mode: 448 });
1580
+ const canonical = await realpath3(directory);
1350
1581
  const contains = (parent, child) => {
1351
- const path = relative2(parent, child);
1352
- return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
1582
+ const path = relative3(parent, child);
1583
+ return path === "" || !isAbsolute2(path) && path !== ".." && !path.startsWith(`..${sep3}`);
1353
1584
  };
1354
1585
  if (contains(root, canonical) || contains(canonical, root))
1355
1586
  throw new Error(
@@ -1361,7 +1592,7 @@ async function stateLocation(root, directory) {
1361
1592
  // apps/client/sessions/hooks.ts
1362
1593
  import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
1363
1594
  import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
1364
- import { isAbsolute as isAbsolute2, join as join4 } from "node:path";
1595
+ import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
1365
1596
  import { z as z9 } from "zod";
1366
1597
 
1367
1598
  // apps/client/sessions/normalize.ts
@@ -1482,17 +1713,17 @@ function normalizeJsonl(agent, text) {
1482
1713
  // apps/client/sessions/hooks.ts
1483
1714
  var HintSchema = z9.object({
1484
1715
  agent: AgentSchema,
1485
- path: z9.string().min(1).max(4096).refine(isAbsolute2),
1486
- projectDir: z9.string().min(1).max(4096).refine(isAbsolute2)
1716
+ path: z9.string().min(1).max(4096).refine(isAbsolute3),
1717
+ projectDir: z9.string().min(1).max(4096).refine(isAbsolute3)
1487
1718
  });
1488
1719
  function inbox(directory, root) {
1489
- return join4(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
1720
+ return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
1490
1721
  }
1491
1722
  async function recordHint(directory, root, input) {
1492
1723
  const hint = HintSchema.parse(input);
1493
1724
  const targetDir = inbox(directory, root);
1494
1725
  await mkdir4(targetDir, { recursive: true, mode: 448 });
1495
- const target = join4(
1726
+ const target = join5(
1496
1727
  targetDir,
1497
1728
  `${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
1498
1729
  );
@@ -1518,7 +1749,7 @@ async function consumeHints(directory, root, sessions) {
1518
1749
  for await (const entry of dir) {
1519
1750
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
1520
1751
  if (count++ >= 50) break;
1521
- const path = join4(inbox(directory, root), entry.name);
1752
+ const path = join5(inbox(directory, root), entry.name);
1522
1753
  try {
1523
1754
  const handle = await open2(path, "r");
1524
1755
  let body;
@@ -1584,8 +1815,8 @@ async function runSessionCommand(args) {
1584
1815
  const { values } = parseArgs({
1585
1816
  args: args.slice(1),
1586
1817
  options: {
1587
- root: { type: "string", default: resolve2("workspaces") },
1588
- "state-dir": { type: "string", default: join5(homedir(), ".teammy", "client") },
1818
+ root: { type: "string", default: resolve3("workspaces") },
1819
+ "state-dir": { type: "string", default: join6(homedir(), ".teammy", "client") },
1589
1820
  agent: { type: "string" },
1590
1821
  file: { type: "string" },
1591
1822
  directory: { type: "string" },
@@ -1604,16 +1835,16 @@ async function runSessionCommand(args) {
1604
1835
  process.stdout.write(help);
1605
1836
  return 0;
1606
1837
  }
1607
- const root = await realpath3(resolve2(values.root));
1838
+ const root = await realpath4(resolve3(values.root));
1608
1839
  const directory = await stateLocation(root, values["state-dir"]);
1609
- const projectDir = await realpath3(resolve2(values["project-dir"] ?? dirname3(root)));
1840
+ const projectDir = await realpath4(resolve3(values["project-dir"] ?? dirname4(root)));
1610
1841
  if (command === "hook") {
1611
1842
  const payload = values.file ? {} : await hookPayload();
1612
1843
  const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
1613
1844
  if (typeof file === "string" && file)
1614
1845
  await recordHint(directory, root, {
1615
1846
  agent: AgentSchema.parse(values.agent),
1616
- path: resolve2(file),
1847
+ path: resolve3(file),
1617
1848
  projectDir
1618
1849
  });
1619
1850
  return 0;
@@ -1627,7 +1858,7 @@ async function runSessionCommand(args) {
1627
1858
  let output;
1628
1859
  if (command === "track" || command === "untrack") {
1629
1860
  if (!values.file) throw new Error("--file is required.");
1630
- const path = resolve2(values.file);
1861
+ const path = resolve3(values.file);
1631
1862
  if (command === "track")
1632
1863
  sessions.track(
1633
1864
  AgentSchema.parse(values.agent),
@@ -1640,23 +1871,23 @@ async function runSessionCommand(args) {
1640
1871
  } else if (command === "discover" || command === "undiscover") {
1641
1872
  if (command === "undiscover") {
1642
1873
  if (!values.directory) throw new Error("--directory is required.");
1643
- state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath3(resolve2(values.directory)));
1874
+ state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath4(resolve3(values.directory)));
1644
1875
  } else {
1645
1876
  const agent = AgentSchema.parse(values.agent);
1646
1877
  const defaults = {
1647
- "claude-code": join5(homedir(), ".claude", "projects"),
1648
- codex: join5(process.env.CODEX_HOME ?? join5(homedir(), ".codex"), "sessions"),
1649
- cursor: join5(
1650
- process.env.CURSOR_CONFIG_DIR ?? join5(homedir(), ".cursor"),
1878
+ "claude-code": join6(homedir(), ".claude", "projects"),
1879
+ codex: join6(process.env.CODEX_HOME ?? join6(homedir(), ".codex"), "sessions"),
1880
+ cursor: join6(
1881
+ process.env.CURSOR_CONFIG_DIR ?? join6(homedir(), ".cursor"),
1651
1882
  "chats",
1652
1883
  createHash6("md5").update(projectDir).digest("hex")
1653
1884
  ),
1654
- pi: join5(process.env.PI_CODING_AGENT_DIR ?? join5(homedir(), ".pi", "agent"), "sessions"),
1885
+ pi: join6(process.env.PI_CODING_AGENT_DIR ?? join6(homedir(), ".pi", "agent"), "sessions"),
1655
1886
  dsh: void 0
1656
1887
  };
1657
1888
  const path = values.directory ?? defaults[agent];
1658
1889
  if (!path) throw new Error("DSH requires its configured persistence --directory.");
1659
- sessions.discover(agent, await realpath3(resolve2(path)), projectDir);
1890
+ sessions.discover(agent, await realpath4(resolve3(path)), projectDir);
1660
1891
  }
1661
1892
  output = sessions.directories();
1662
1893
  } else if (command === "sources")
@@ -1691,32 +1922,482 @@ async function runSessionCommand(args) {
1691
1922
  }
1692
1923
 
1693
1924
  // apps/client/sync-command.ts
1694
- import { realpath as realpath5 } from "node:fs/promises";
1695
- import { homedir as homedir2 } from "node:os";
1696
- import { join as join10, resolve as resolve5 } from "node:path";
1697
- import { setTimeout as setTimeout3 } from "node:timers/promises";
1925
+ import { parseArgs as parseArgs3 } from "node:util";
1926
+ import { setTimeout as setTimeout4 } from "node:timers/promises";
1927
+
1928
+ // apps/client/client-command.ts
1698
1929
  import { parseArgs as parseArgs2 } from "node:util";
1930
+ import { join as join10, resolve as resolve5 } from "node:path";
1931
+ import { homedir as homedir3 } from "node:os";
1932
+ import { createInterface as createInterface2 } from "node:readline/promises";
1699
1933
 
1700
- // apps/client/sessions/synchronizer.ts
1701
- import { randomUUID as randomUUID6 } from "node:crypto";
1702
- import { join as join8 } from "node:path";
1934
+ // apps/client/connection.ts
1935
+ import { readFile, mkdir as mkdir5 } from "node:fs/promises";
1936
+ import { createInterface } from "node:readline/promises";
1703
1937
 
1704
- // packages/protocol/session-key.ts
1938
+ // apps/client/root-lock.ts
1705
1939
  import { createHash as createHash7 } from "node:crypto";
1706
- function sessionKey(agent, nativeId) {
1707
- return createHash7("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
1940
+ import { join as join7 } from "node:path";
1941
+ function acquireRootLock(root, stateDirectory) {
1942
+ let lock;
1943
+ try {
1944
+ lock = openDatabase(
1945
+ join7(stateDirectory, "locks", `${createHash7("sha256").update(root).digest("hex")}.sqlite3`),
1946
+ [],
1947
+ 0
1948
+ );
1949
+ lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
1950
+ return lock;
1951
+ } catch (error) {
1952
+ lock?.close();
1953
+ if (error instanceof Error && /locked|busy/.test(error.message))
1954
+ throw new Error("Another sync or connect process is using this root.");
1955
+ throw error;
1956
+ }
1708
1957
  }
1709
1958
 
1710
- // packages/storage/local-blob-store.ts
1711
- import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
1712
- import { link, mkdir as mkdir5, open as open3, readFile as readFile2, unlink as unlink3 } from "node:fs/promises";
1713
- import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
1714
- function hash(content) {
1715
- return createHash8("sha256").update(content).digest("hex");
1716
- }
1717
- function hasCode(error, code) {
1718
- return error instanceof Error && "code" in error && error.code === code;
1719
- }
1959
+ // apps/client/connection.ts
1960
+ async function connectProject(state, root, directory, options) {
1961
+ const lock = acquireRootLock(root, directory);
1962
+ try {
1963
+ state.assertRootAvailable(root);
1964
+ const transport = new HubClient(options.url);
1965
+ const existing = state.binding(root);
1966
+ const project = new ClientProjectState(state, root);
1967
+ if (existing && existing.serverUrl !== transport.url)
1968
+ throw new Error("This root is connected to another server.");
1969
+ if (existing && options.project && options.project !== existing.workspaceId)
1970
+ throw new Error("This root belongs to a different project. Use a different local root.");
1971
+ const info = await transport.serverInfo();
1972
+ if (!info.capabilities.includes("multi-project"))
1973
+ throw new Error(
1974
+ "This client requires a multi-project server. Update the server before connecting."
1975
+ );
1976
+ let member = options.member;
1977
+ if (existing && !options.reconnect && !options.tokenFile) {
1978
+ if (member && member !== existing.memberId)
1979
+ throw new Error("This root belongs to a different member.");
1980
+ const client2 = new HubClient(existing.serverUrl, 1e4, existing);
1981
+ await client2.members();
1982
+ } else if (options.tokenFile) {
1983
+ const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
1984
+ const deviceId = state.deviceFor(root);
1985
+ const client2 = new HubClient(options.url, 1e4, { token, deviceId });
1986
+ const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
1987
+ if (options.project && binding2.workspaceId !== options.project)
1988
+ throw new Error("Token belongs to a different project.");
1989
+ state.saveBinding(root, client2.url, token, binding2);
1990
+ } else {
1991
+ member ??= existing?.memberId;
1992
+ if (!member && process.stdin.isTTY) {
1993
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1994
+ try {
1995
+ member = (await rl.question("Member ID: ")).trim();
1996
+ } finally {
1997
+ rl.close();
1998
+ }
1999
+ }
2000
+ if (!member) throw new Error("--member is required when input is not interactive.");
2001
+ NameSchema.parse(member);
2002
+ if (existing && existing.memberId !== member)
2003
+ throw new Error("This root belongs to a different member.");
2004
+ const projectId = options.project ?? existing?.workspaceId;
2005
+ if (!projectId)
2006
+ throw new Error(
2007
+ "--project is required for a new connection; copy the command from the project settings."
2008
+ );
2009
+ NameSchema.parse(projectId);
2010
+ const request = project.enrollment(
2011
+ transport.url,
2012
+ member,
2013
+ projectId,
2014
+ Boolean(options.reconnect)
2015
+ );
2016
+ const binding2 = await transport.enroll(request);
2017
+ state.saveBinding(root, transport.url, request.credential, binding2);
2018
+ project.completeEnrollment();
2019
+ }
2020
+ const binding = state.binding(root);
2021
+ const client = new HubClient(binding.serverUrl, 1e4, binding);
2022
+ if (info.capabilities.includes("project-config")) {
2023
+ try {
2024
+ await applyProjectFile(project, await client.projectConfig());
2025
+ } catch (error) {
2026
+ if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
2027
+ throw error;
2028
+ }
2029
+ }
2030
+ const own = await metadata(root, binding.memberId);
2031
+ if (own && !own.directory) throw new Error("Member path is not a directory.");
2032
+ if (!own) await mkdir5(await safePath(root, binding.memberId));
2033
+ const { token: _, ...visible } = binding;
2034
+ return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
2035
+ } finally {
2036
+ lock.close();
2037
+ }
2038
+ }
2039
+
2040
+ // apps/client/runtime/ipc.ts
2041
+ import { createConnection } from "node:net";
2042
+ import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
2043
+ import { join as join8 } from "node:path";
2044
+ import { z as z10 } from "zod";
2045
+ var RequestSchema = z10.object({
2046
+ method: z10.enum(["ping", "wake", "status", "once"]),
2047
+ root: z10.string().max(4096).optional()
2048
+ }).strict();
2049
+ function socketPath(directory) {
2050
+ return join8(
2051
+ "/tmp",
2052
+ `teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
2053
+ "control.sock"
2054
+ );
2055
+ }
2056
+ async function prepareSocketDirectory(directory) {
2057
+ const path = join8(socketPath(directory), "..");
2058
+ await mkdir6(path, { recursive: true, mode: 448 });
2059
+ const stat2 = await lstat3(path);
2060
+ if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
2061
+ throw new Error("Unsafe daemon socket directory.");
2062
+ await chmod(path, 448);
2063
+ }
2064
+ async function daemonRequest(directory, input, timeout = 15e3) {
2065
+ RequestSchema.parse(input);
2066
+ return new Promise((resolve9, reject) => {
2067
+ const socket = createConnection(socketPath(directory));
2068
+ socket.setEncoding("utf8");
2069
+ let body = "";
2070
+ let done = false;
2071
+ const finish = (error, result2) => {
2072
+ if (done) return;
2073
+ done = true;
2074
+ socket.destroy();
2075
+ if (error) reject(error);
2076
+ else resolve9(result2);
2077
+ };
2078
+ socket.setTimeout(
2079
+ timeout,
2080
+ () => finish(new Error("Background service did not respond. Run teammy status."))
2081
+ );
2082
+ socket.on("error", (e) => finish(e));
2083
+ socket.on("end", () => {
2084
+ if (!done)
2085
+ finish(
2086
+ Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
2087
+ );
2088
+ });
2089
+ socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
2090
+ socket.on("data", (chunk) => {
2091
+ body += chunk.toString();
2092
+ if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
2093
+ if (!body.includes("\n")) return;
2094
+ try {
2095
+ const response = JSON.parse(body.split("\n")[0]);
2096
+ if (response.ok) finish(void 0, response.result);
2097
+ else finish(new Error(response.error ?? "Background operation failed."));
2098
+ } catch (e) {
2099
+ finish(e);
2100
+ }
2101
+ });
2102
+ });
2103
+ }
2104
+ async function runningDaemon(directory) {
2105
+ try {
2106
+ return await daemonRequest(directory, { method: "ping" }, 1e3);
2107
+ } catch (error) {
2108
+ if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
2109
+ String(error.code)
2110
+ ))
2111
+ return null;
2112
+ throw error;
2113
+ }
2114
+ }
2115
+
2116
+ // apps/client/runtime/service.ts
2117
+ import { execFile as execFile2 } from "node:child_process";
2118
+ import { promisify as promisify2 } from "node:util";
2119
+ import { homedir as homedir2 } from "node:os";
2120
+ import { dirname as dirname5, join as join9, resolve as resolve4 } from "node:path";
2121
+ import { mkdir as mkdir7, readFile as readFile2, writeFile, rename as rename3 } from "node:fs/promises";
2122
+ import { setTimeout as setTimeout3 } from "node:timers/promises";
2123
+ var execute = promisify2(execFile2);
2124
+ var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
2125
+ var xml = (s) => s.replace(
2126
+ /[<>&"']/g,
2127
+ (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]
2128
+ );
2129
+ var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
2130
+ function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir2()) {
2131
+ for (const value of [directory, executable, entry])
2132
+ if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
2133
+ const id = `com.teammy.client.${rootKey(directory)}`;
2134
+ const args = [executable, entry, "_daemon", "--state-dir", directory];
2135
+ if (platform === "darwin")
2136
+ return {
2137
+ id,
2138
+ path: join9(userHome, "Library", "LaunchAgents", id + ".plist"),
2139
+ content: `<?xml version="1.0" encoding="UTF-8"?>
2140
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2141
+ <plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
2142
+ `
2143
+ };
2144
+ if (platform === "linux")
2145
+ return {
2146
+ id: id + ".service",
2147
+ path: join9(userHome, ".config", "systemd", "user", id + ".service"),
2148
+ content: `[Unit]
2149
+ Description=Teammy background synchronization
2150
+ [Service]
2151
+ Type=simple
2152
+ ExecStart=${args.map(unit).join(" ")}
2153
+ Restart=always
2154
+ RestartSec=30
2155
+ UMask=0077
2156
+ StandardOutput=null
2157
+ StandardError=journal
2158
+ [Install]
2159
+ WantedBy=default.target
2160
+ `
2161
+ };
2162
+ throw new Error(
2163
+ "Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
2164
+ );
2165
+ }
2166
+ async function checkServiceSupport() {
2167
+ try {
2168
+ if (process.platform === "darwin")
2169
+ await exec2("launchctl", ["print", `gui/${process.getuid()}`], {
2170
+ maxBuffer: 4 * 1024 * 1024
2171
+ });
2172
+ else if (process.platform === "linux") await exec2("systemctl", ["--user", "show-environment"]);
2173
+ else throw new Error("unsupported");
2174
+ } catch {
2175
+ throw new Error(
2176
+ "A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
2177
+ );
2178
+ }
2179
+ }
2180
+ async function bootstrapLaunchAgent(path) {
2181
+ const deadline = Date.now() + 1e4;
2182
+ for (; ; ) {
2183
+ try {
2184
+ await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
2185
+ return;
2186
+ } catch (error) {
2187
+ if (error.code !== 5 || Date.now() >= deadline) throw error;
2188
+ await setTimeout3(250);
2189
+ }
2190
+ }
2191
+ }
2192
+ async function ensureDaemon(directory, version) {
2193
+ const definition = serviceDefinition(directory, process.execPath, resolve4(process.argv[1]));
2194
+ let old = "";
2195
+ try {
2196
+ old = await readFile2(definition.path, "utf8");
2197
+ } catch (e) {
2198
+ if (e.code !== "ENOENT") throw e;
2199
+ }
2200
+ const alive = await runningDaemon(directory);
2201
+ if (alive && old === definition.content && alive.version === version) return;
2202
+ await checkServiceSupport();
2203
+ await mkdir7(dirname5(definition.path), { recursive: true });
2204
+ if (old !== definition.content) {
2205
+ const temp = definition.path + `.${process.pid}.tmp`;
2206
+ await writeFile(temp, definition.content, { mode: 384 });
2207
+ await rename3(temp, definition.path);
2208
+ }
2209
+ if (process.platform === "darwin") {
2210
+ const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
2211
+ let loaded = false;
2212
+ try {
2213
+ await exec2("launchctl", ["print", target]);
2214
+ loaded = true;
2215
+ } catch {
2216
+ }
2217
+ if (loaded && (old !== definition.content || alive?.version !== version)) {
2218
+ await exec2("launchctl", ["bootout", target]);
2219
+ loaded = false;
2220
+ }
2221
+ if (!loaded) await bootstrapLaunchAgent(definition.path);
2222
+ await exec2("launchctl", ["kickstart", target]);
2223
+ } else {
2224
+ await exec2("systemctl", ["--user", "daemon-reload"]);
2225
+ await exec2("systemctl", ["--user", "enable", definition.id]);
2226
+ await exec2("systemctl", [
2227
+ "--user",
2228
+ old !== definition.content || alive?.version !== version ? "restart" : "start",
2229
+ definition.id
2230
+ ]);
2231
+ }
2232
+ const deadline = Date.now() + 15e3;
2233
+ while (Date.now() < deadline) {
2234
+ if ((await runningDaemon(directory))?.version === version) return;
2235
+ await setTimeout3(150);
2236
+ }
2237
+ throw new Error(
2238
+ "Background service did not start. Run teammy logs and check the user service manager."
2239
+ );
2240
+ }
2241
+ async function wakeDaemon(directory, version) {
2242
+ await ensureDaemon(directory, version);
2243
+ await daemonRequest(directory, { method: "wake" });
2244
+ }
2245
+
2246
+ // apps/client/output.ts
2247
+ var styled = () => Boolean(
2248
+ process.stdout.isTTY && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb"
2249
+ );
2250
+ function bold(value) {
2251
+ const clean = redact(value);
2252
+ return styled() ? `\x1B[1m${clean}\x1B[22m` : clean;
2253
+ }
2254
+ function result(title, detail) {
2255
+ process.stdout.write(`${bold(title)}${detail ? `
2256
+ ${redact(detail)}` : ""}
2257
+ `);
2258
+ }
2259
+ function json(value) {
2260
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
2261
+ }
2262
+
2263
+ // apps/client/client-command.ts
2264
+ var locationOptions = {
2265
+ root: { type: "string", default: resolve5("workspaces") },
2266
+ "state-dir": { type: "string", default: join10(homedir3(), ".teammy", "client") },
2267
+ json: { type: "boolean" },
2268
+ help: { type: "boolean", short: "h" }
2269
+ };
2270
+ async function runClientCommand(command, args, version) {
2271
+ const { values } = parseArgs2({
2272
+ args,
2273
+ options: {
2274
+ ...locationOptions,
2275
+ url: { type: "string" },
2276
+ project: { type: "string" },
2277
+ member: { type: "string" },
2278
+ "token-file": { type: "string" },
2279
+ reconnect: { type: "boolean" },
2280
+ "no-start": { type: "boolean" },
2281
+ all: { type: "boolean" }
2282
+ }
2283
+ });
2284
+ if (values.help) {
2285
+ process.stdout.write(
2286
+ command === "connect" ? "Usage: teammy connect --url URL --project ID --member ID [--root DIR]\n\nCreates ./workspaces, adds Git ignore rules and starts login-session background sync.\nAdvanced: --no-start, --reconnect (stop sync first), --token-file FILE, --state-dir DIR, --json\n" : `Usage: teammy ${command} [--root DIR] [--state-dir DIR] [--json]${command === "logs" ? " [--all]" : ""}
2287
+ `
2288
+ );
2289
+ return 0;
2290
+ }
2291
+ const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
2292
+ const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
2293
+ try {
2294
+ if (command === "logs") {
2295
+ const entries = log.tail(values.all ? void 0 : root);
2296
+ if (values.json) json(entries);
2297
+ else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
2298
+ else
2299
+ for (const item of entries)
2300
+ process.stdout.write(
2301
+ `${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
2302
+ `
2303
+ );
2304
+ return 0;
2305
+ }
2306
+ if (command === "status") {
2307
+ const binding = state.binding(root);
2308
+ if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
2309
+ const daemon = await runningDaemon(directory);
2310
+ const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
2311
+ const { token: _, ...publicBinding } = binding;
2312
+ if (values.json)
2313
+ json({
2314
+ ...current,
2315
+ binding: publicBinding,
2316
+ control: readControlStatus(state, root),
2317
+ daemon,
2318
+ logPath: log.path
2319
+ });
2320
+ else {
2321
+ const status = current.status;
2322
+ const title = !current.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : status?.pendingDownloads || !status?.lastSuccessAt ? "\u9996\u6B21\u540C\u6B65\u4E2D" : "\u540C\u6B65\u5DF2\u5F00\u542F";
2323
+ result(
2324
+ title,
2325
+ new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
2326
+ );
2327
+ if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
2328
+ else if (status?.lastSuccessAt)
2329
+ process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
2330
+ `);
2331
+ if (status?.pendingFiles || status?.pendingDownloads)
2332
+ process.stdout.write(
2333
+ ` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
2334
+ `
2335
+ );
2336
+ }
2337
+ return 0;
2338
+ }
2339
+ const existing = state.binding(root);
2340
+ const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
2341
+ if (!url) throw new Error("--url is required.");
2342
+ const transport = new HubClient(url);
2343
+ const selectedProject = values.project ?? (values.reconnect ? existing?.workspaceId : void 0);
2344
+ const project = selectedProject === void 0 && values["token-file"] ? void 0 : NameSchema.parse(selectedProject);
2345
+ let member = values.member ?? (values.reconnect ? existing?.memberId : void 0);
2346
+ if (!member && !values["token-file"] && process.stdin.isTTY) {
2347
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
2348
+ try {
2349
+ member = (await rl.question("Member ID: ")).trim();
2350
+ } finally {
2351
+ rl.close();
2352
+ }
2353
+ }
2354
+ if (!values["token-file"]) member = NameSchema.parse(member);
2355
+ await prepareWorkspace(
2356
+ state,
2357
+ root,
2358
+ { url: transport.url, project: project ?? "", member: member ?? "" },
2359
+ Boolean(values.reconnect)
2360
+ );
2361
+ if (!values["no-start"]) await checkServiceSupport();
2362
+ const connected = await connectProject(state, root, directory, {
2363
+ url: transport.url,
2364
+ project,
2365
+ member,
2366
+ tokenFile: values["token-file"],
2367
+ reconnect: values.reconnect
2368
+ });
2369
+ completePreparation(state, root);
2370
+ runtime.enable(root, !values["no-start"]);
2371
+ log.write("workspace.connected", root);
2372
+ if (!values["no-start"]) await wakeDaemon(directory, version);
2373
+ if (values.json) json({ ...connected, syncEnabled: !values["no-start"] });
2374
+ else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
2375
+ return 0;
2376
+ } catch (e) {
2377
+ log.write(`${command}.failed`, root, diagnostic(e));
2378
+ throw e;
2379
+ } finally {
2380
+ state.close();
2381
+ }
2382
+ }
2383
+
2384
+ // apps/client/files/synchronizer.ts
2385
+ import { randomUUID as randomUUID6 } from "node:crypto";
2386
+ import { watch } from "node:fs";
2387
+ import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
2388
+ import { join as join12, sep as sep4 } from "node:path";
2389
+ import { performance } from "node:perf_hooks";
2390
+
2391
+ // packages/storage/local-blob-store.ts
2392
+ import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
2393
+ import { link, mkdir as mkdir8, open as open3, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
2394
+ import { dirname as dirname6, join as join11, resolve as resolve6 } from "node:path";
2395
+ function hash(content) {
2396
+ return createHash8("sha256").update(content).digest("hex");
2397
+ }
2398
+ function hasCode(error, code) {
2399
+ return error instanceof Error && "code" in error && error.code === code;
2400
+ }
1720
2401
  var BlobIntegrityError = class extends Error {
1721
2402
  constructor(sha256) {
1722
2403
  super(`Stored content failed integrity verification: ${sha256}`);
@@ -1728,13 +2409,13 @@ var BlobIntegrityError = class extends Error {
1728
2409
  var LocalBlobStore = class {
1729
2410
  root;
1730
2411
  constructor(root) {
1731
- this.root = resolve3(root);
2412
+ this.root = resolve6(root);
1732
2413
  }
1733
2414
  async put(content) {
1734
2415
  const bytes = Buffer.from(content);
1735
2416
  const sha256 = hash(bytes);
1736
2417
  const target = this.pathFor(sha256);
1737
- await mkdir5(dirname4(target), { recursive: true, mode: 448 });
2418
+ await mkdir8(dirname6(target), { recursive: true, mode: 448 });
1738
2419
  const temporary = `${target}.${randomUUID5()}.tmp`;
1739
2420
  const file = await open3(temporary, "wx", 384);
1740
2421
  try {
@@ -1751,7 +2432,7 @@ var LocalBlobStore = class {
1751
2432
  await this.get(sha256);
1752
2433
  }
1753
2434
  if (process.platform !== "win32") {
1754
- let directory = dirname4(target);
2435
+ let directory = dirname6(target);
1755
2436
  for (; ; ) {
1756
2437
  const handle = await open3(directory, "r");
1757
2438
  try {
@@ -1759,8 +2440,8 @@ var LocalBlobStore = class {
1759
2440
  } finally {
1760
2441
  await handle.close();
1761
2442
  }
1762
- if (directory === dirname4(this.root)) break;
1763
- directory = dirname4(directory);
2443
+ if (directory === dirname6(this.root)) break;
2444
+ directory = dirname6(directory);
1764
2445
  }
1765
2446
  }
1766
2447
  } finally {
@@ -1772,7 +2453,7 @@ var LocalBlobStore = class {
1772
2453
  const target = this.pathFor(sha256);
1773
2454
  let content;
1774
2455
  try {
1775
- content = await readFile2(target);
2456
+ content = await readFile3(target);
1776
2457
  } catch (error) {
1777
2458
  if (hasCode(error, "ENOENT")) return void 0;
1778
2459
  throw error;
@@ -1782,994 +2463,1339 @@ var LocalBlobStore = class {
1782
2463
  }
1783
2464
  pathFor(sha256) {
1784
2465
  if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
1785
- return join6(this.root, "sha256", sha256.slice(0, 2), sha256);
2466
+ return join11(this.root, "sha256", sha256.slice(0, 2), sha256);
1786
2467
  }
1787
2468
  };
1788
2469
 
1789
- // apps/client/sessions/source.ts
1790
- import { open as open4, stat, realpath as realpath4 } from "node:fs/promises";
1791
- import { relative as relative3, isAbsolute as isAbsolute3, sep as sep3 } from "node:path";
1792
- import * as zlib from "node:zlib";
1793
-
1794
- // apps/client/sessions/cursor.ts
1795
- import { createHash as createHash9 } from "node:crypto";
1796
- import { basename, dirname as dirname5, resolve as resolve4 } from "node:path";
1797
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1798
- import { fileURLToPath } from "node:url";
1799
- function fields(bytes) {
1800
- const data = Buffer.from(bytes);
1801
- let offset = 0;
1802
- const varint = () => {
1803
- let value = 0n;
1804
- for (let i = 0; i < 10; i++) {
1805
- if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
1806
- const byte = data[offset++];
1807
- value |= BigInt(byte & 127) << BigInt(i * 7);
1808
- if (byte < 128) return value;
2470
+ // apps/client/publication.ts
2471
+ var PublicationQueue = class {
2472
+ constructor(state, blobs, transport) {
2473
+ this.state = state;
2474
+ this.blobs = blobs;
2475
+ this.transport = transport;
2476
+ }
2477
+ state;
2478
+ blobs;
2479
+ transport;
2480
+ async flush(root, now = Date.now()) {
2481
+ const pending = this.state.pending(root);
2482
+ if (!pending || pending.retryAt > now) return false;
2483
+ try {
2484
+ const hashes = [
2485
+ ...new Set(
2486
+ pending.batch.changes.flatMap(
2487
+ (change) => change.kind === "upsert" ? [change.sha256] : []
2488
+ )
2489
+ )
2490
+ ];
2491
+ const { missing: missing2 } = await this.transport.missingBlobs(hashes);
2492
+ for (const hash2 of missing2) {
2493
+ if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
2494
+ const content = await this.blobs.get(hash2);
2495
+ if (!content) throw new Error("A queued content snapshot is missing from local storage.");
2496
+ const result2 = await this.transport.uploadBlob(hash2, content);
2497
+ if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
2498
+ throw new Error("Server acknowledged different content.");
2499
+ }
2500
+ this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
2501
+ return true;
2502
+ } catch (error) {
2503
+ const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2504
+ this.state.postpone(
2505
+ root,
2506
+ pending.batch.operationId,
2507
+ error instanceof Error ? error.message : "Publication failed.",
2508
+ now,
2509
+ blocked
2510
+ );
2511
+ throw error;
1809
2512
  }
1810
- throw new Error("Invalid Cursor protobuf integer.");
1811
- };
1812
- const result = /* @__PURE__ */ new Map();
1813
- while (offset < data.length) {
1814
- const tag = varint();
1815
- const field = Number(tag >> 3n);
1816
- if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
1817
- const wire = Number(tag & 7n);
1818
- if (wire === 0) varint();
1819
- else if (wire === 1) offset += 8;
1820
- else if (wire === 5) offset += 4;
1821
- else if (wire === 2) {
1822
- const length = Number(varint());
1823
- if (!Number.isSafeInteger(length) || length > data.length - offset)
1824
- throw new Error("Truncated Cursor protobuf value.");
1825
- const values = result.get(field) ?? [];
1826
- values.push(data.subarray(offset, offset + length));
1827
- result.set(field, values);
1828
- offset += length;
1829
- } else throw new Error("Unsupported Cursor protobuf wire type.");
1830
- if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
1831
2513
  }
1832
- return result;
1833
- }
1834
- function readCursor(path, projectDir) {
1835
- const db = new DatabaseSync2(path, { readOnly: true });
1836
- try {
1837
- db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
1838
- if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
1839
- throw new Error("Unsupported Cursor CLI store version (expected 1).");
1840
- const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
1841
- const hex = row?.value;
1842
- if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
1843
- throw new Error("Invalid Cursor metadata.");
1844
- const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
1845
- if (typeof meta.agentId !== "string" || meta.agentId !== basename(dirname5(path)) || meta.subagentInfo != null)
1846
- throw new Error("Invalid or delegated Cursor session identity.");
1847
- let budget = 64 * 1024 * 1024;
1848
- const blob = (id) => {
1849
- if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
1850
- const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
1851
- if (!(value instanceof Uint8Array))
1852
- throw new Error("Cursor history missing or exceeds 64 MiB.");
1853
- const bytes = Buffer.from(value);
1854
- budget -= bytes.length;
1855
- if (createHash9("sha256").update(bytes).digest("hex") !== id)
1856
- throw new Error("Cursor blob failed integrity verification.");
1857
- return bytes;
2514
+ };
2515
+
2516
+ // apps/client/files/synchronizer.ts
2517
+ var FileSynchronizer = class {
2518
+ constructor(state, root, stateDirectory, options = {}) {
2519
+ this.state = state;
2520
+ this.root = root;
2521
+ this.options = options;
2522
+ const binding = state.binding(root);
2523
+ if (!binding) throw new Error("Connect this root before synchronizing.");
2524
+ this.binding = binding;
2525
+ this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2526
+ this.blobs = new LocalBlobStore(join12(stateDirectory, "blobs"));
2527
+ this.queue = new PublicationQueue(state, this.blobs, this.client);
2528
+ this.index = new SyncState(state, root);
2529
+ this.ownRoot = join12(root, binding.memberId);
2530
+ const started = performance.now();
2531
+ const epoch = Date.now();
2532
+ this.clock = options.clock ?? (() => epoch + performance.now() - started);
2533
+ this.settleMs = options.settleMs ?? 6e4;
2534
+ this.index.restartWaiting(this.clock());
2535
+ }
2536
+ state;
2537
+ root;
2538
+ options;
2539
+ index;
2540
+ binding;
2541
+ client;
2542
+ blobs;
2543
+ queue;
2544
+ ownRoot;
2545
+ clock;
2546
+ settleMs;
2547
+ watcher;
2548
+ watchIssues = [];
2549
+ lastScan = -Infinity;
2550
+ busy = false;
2551
+ stopped = false;
2552
+ abort = new AbortController();
2553
+ async checkCapabilities() {
2554
+ const info = await this.client.serverInfo();
2555
+ if (!["identity", "blobs", "file-revisions"].every(
2556
+ (capability) => info.capabilities.includes(capability)
2557
+ ))
2558
+ throw new Error("Server does not support file synchronization.");
2559
+ }
2560
+ setSettleMs(value) {
2561
+ if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
2562
+ throw new Error("Invalid stable wait interval.");
2563
+ this.settleMs = value;
2564
+ }
2565
+ close() {
2566
+ this.stopped = true;
2567
+ this.abort.abort();
2568
+ this.watcher?.close();
2569
+ this.watcher = void 0;
2570
+ }
2571
+ async cycle() {
2572
+ if (this.busy || this.stopped) throw new Error("Synchronizer is already running or closed.");
2573
+ this.busy = true;
2574
+ const settleMs = this.settleMs;
2575
+ const report = {
2576
+ published: false,
2577
+ appliedFiles: 0,
2578
+ waitingFiles: 0,
2579
+ pendingPublication: false,
2580
+ issues: this.watchIssues.splice(0)
1858
2581
  };
1859
- const root = fields(blob(String(meta.latestRootBlobId)));
1860
- const bucket = basename(dirname5(dirname5(path)));
1861
- const md5 = (cwd2) => createHash9("md5").update(resolve4(cwd2)).digest("hex");
1862
- let cwd = md5(projectDir) === bucket ? projectDir : void 0;
1863
- for (const uri of root.get(9) ?? []) {
2582
+ try {
1864
2583
  try {
1865
- const native = fileURLToPath(uri.toString("utf8"));
1866
- if (md5(native) === bucket) {
1867
- cwd = native;
1868
- break;
2584
+ await this.initializeAuthor();
2585
+ const ownStat = await lstat4(await safePath(this.root, this.binding.memberId));
2586
+ if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
2587
+ throw new Error("Author directory is unavailable or is a symlink.");
2588
+ this.listen();
2589
+ if (this.clock() - this.lastScan >= (this.options.reconcileMs ?? 3e5)) {
2590
+ await this.reconcile("", report.issues);
2591
+ this.lastScan = this.clock();
2592
+ }
2593
+ const blocked = await this.refreshDirty(report.issues);
2594
+ await this.audit(report.issues);
2595
+ report.published = await this.queue.flush(this.root, this.clock());
2596
+ if (!this.state.pending(this.root) && !this.stopped) {
2597
+ await this.stage(blocked, report.issues, settleMs);
2598
+ report.published = await this.queue.flush(this.root, this.clock()) || report.published;
2599
+ }
2600
+ } catch (error) {
2601
+ report.issues.push(`publish: ${message(error)}`);
2602
+ }
2603
+ if (!this.stopped) {
2604
+ try {
2605
+ for (const member of (await this.client.members()).members) {
2606
+ if (member === this.binding.memberId || this.stopped) continue;
2607
+ try {
2608
+ report.appliedFiles += await this.receive(member);
2609
+ } catch (error) {
2610
+ report.issues.push(`${member}: ${message(error)}`);
2611
+ }
2612
+ }
2613
+ } catch (error) {
2614
+ report.issues.push(`receive: ${message(error)}`);
1869
2615
  }
1870
- } catch {
1871
2616
  }
2617
+ report.waitingFiles = this.index.index(true).length;
2618
+ report.pendingPublication = Boolean(this.state.pending(this.root));
2619
+ this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
2620
+ return report;
2621
+ } finally {
2622
+ this.busy = false;
1872
2623
  }
1873
- if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
1874
- const refs = [];
1875
- for (const archive of root.get(13) ?? root.get(11) ?? [])
1876
- refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
1877
- refs.push(...root.get(1) ?? []);
1878
- if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
1879
- const messages = [];
1880
- const counts = /* @__PURE__ */ new Map();
1881
- for (const ref of refs) {
1882
- const key = ref.toString("hex");
1883
- const message2 = object(
1884
- JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
1885
- );
1886
- const cursor = object(object(message2.providerOptions).cursor);
1887
- if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
1888
- let text = textContent(message2.content);
1889
- if (message2.role === "user") {
1890
- if (!cursor.requestId) continue;
1891
- const start = text.indexOf("<user_query>");
1892
- const end = text.lastIndexOf("</user_query>");
1893
- if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
2624
+ }
2625
+ async manifest(owner) {
2626
+ let revision;
2627
+ let after = "";
2628
+ const files = [];
2629
+ for (; ; ) {
2630
+ const page = await this.client.fileManifest(owner, revision, after);
2631
+ if (revision !== void 0 && page.revision !== revision)
2632
+ throw new Error("Manifest changed version during pagination.");
2633
+ revision = page.revision;
2634
+ files.push(...page.files);
2635
+ if (page.nextCursor === null) return { revision, files };
2636
+ if (page.nextCursor === after || files.length > 1e5)
2637
+ throw new Error("Manifest pagination did not make bounded progress.");
2638
+ after = page.nextCursor;
2639
+ }
2640
+ }
2641
+ async initializeAuthor() {
2642
+ if (this.state.publication(this.root).initialized) return;
2643
+ const manifest = await this.manifest(this.binding.memberId);
2644
+ if (manifest.revision > 0 && this.options.adoptBaseline) {
2645
+ for (const file of manifest.files) {
2646
+ const local = await readStable(this.ownRoot, file.path);
2647
+ if (local.sha256 !== file.sha256)
2648
+ throw new Error(
2649
+ `Cannot adopt baseline: local ${file.path} differs from the published file.`
2650
+ );
1894
2651
  }
1895
- if (!text.trim()) continue;
1896
- const id = createHash9("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
1897
- const count = (counts.get(id) ?? 0) + 1;
1898
- counts.set(id, count);
1899
- messages.push({
1900
- id: `${id}#${count}`,
1901
- role: message2.role,
1902
- text,
1903
- source: `blob:${key}`
1904
- });
1905
2652
  }
1906
- return SessionSnapshotSchema.parse({
1907
- formatVersion: 1,
1908
- adapterVersion: 1,
1909
- agent: "cursor",
1910
- nativeId: meta.agentId,
1911
- cwd,
1912
- messages
2653
+ const ownerPath = await safePath(this.root, this.binding.memberId);
2654
+ await mkdir9(ownerPath).catch((error) => {
2655
+ if (error.code !== "EEXIST") throw error;
1913
2656
  });
1914
- } finally {
1915
- db.close();
2657
+ this.state.initializePublication(this.root, manifest.revision, manifest.files);
1916
2658
  }
1917
- }
1918
-
1919
- // apps/client/sessions/source.ts
1920
- var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
1921
- async function sourceSignature(path, agent) {
1922
- const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
1923
- const signatures = [];
1924
- for (const file of paths) {
2659
+ listen() {
2660
+ if (this.options.watch === false || this.watcher || this.stopped) return;
1925
2661
  try {
1926
- const s = await stat(file, { bigint: true });
1927
- if (!s.isFile()) throw new Error("Session source must be a regular file.");
1928
- signatures.push([
1929
- String(s.dev),
1930
- String(s.ino),
1931
- String(s.size),
1932
- String(s.mtimeNs),
1933
- String(s.ctimeNs)
1934
- ]);
2662
+ this.watcher = watch(this.ownRoot, { recursive: true }, (_event, filename) => {
2663
+ if (!filename) {
2664
+ this.lastScan = -Infinity;
2665
+ return;
2666
+ }
2667
+ const path = filename.toString().split(sep4).join("/");
2668
+ if (!ignored(path)) this.index.event(path, this.clock());
2669
+ });
2670
+ this.watcher.on("error", (error) => {
2671
+ this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
2672
+ this.watcher?.close();
2673
+ this.watcher = void 0;
2674
+ this.lastScan = -Infinity;
2675
+ });
1935
2676
  } catch (error) {
1936
- if (file !== path && error.code === "ENOENT")
1937
- signatures.push(null);
1938
- else throw error;
2677
+ this.watchIssues.push(`File watcher unavailable: ${message(error)}; using metadata scans.`);
1939
2678
  }
1940
2679
  }
1941
- return JSON.stringify(signatures);
1942
- }
1943
- async function readSource(agent, path, projectDir) {
1944
- const signature2 = await sourceSignature(path, agent);
1945
- let result;
1946
- if (agent === "cursor") result = { snapshot: readCursor(path, projectDir), incomplete: false };
1947
- else {
1948
- const handle = await open4(path, "r");
1949
- let bytes;
1950
- try {
1951
- const size = (await handle.stat()).size;
1952
- if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
1953
- bytes = Buffer.alloc(size + 1);
1954
- let offset = 0;
1955
- while (offset < bytes.length) {
1956
- const read = await handle.read(bytes, offset, bytes.length - offset, offset);
1957
- if (!read.bytesRead) break;
1958
- offset += read.bytesRead;
1959
- }
1960
- bytes = bytes.subarray(0, offset);
1961
- if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
1962
- } finally {
1963
- await handle.close();
1964
- }
1965
- if (agent === "dsh" && path.endsWith(".zstd")) {
1966
- const decompress = zlib.zstdDecompressSync;
1967
- if (!decompress)
1968
- throw new Error(
1969
- "Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
1970
- );
1971
- bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
1972
- }
1973
- result = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
1974
- }
1975
- if (signature2 !== await sourceSignature(path, agent))
1976
- throw new Error("Session source changed while reading; retry next cycle.");
1977
- const cwd = await realpath4(result.snapshot.cwd);
1978
- const project = await realpath4(projectDir);
1979
- const rel = relative3(project, cwd);
1980
- if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
1981
- throw new Error("Session cwd is outside the registered project.");
1982
- return { ...result, signature: signature2 };
1983
- }
1984
-
1985
- // apps/client/sessions/discovery.ts
1986
- import { opendir as opendir2 } from "node:fs/promises";
1987
- import { join as join7 } from "node:path";
1988
- async function* candidates(directory, agent) {
1989
- const queue = [directory];
1990
- while (queue.length) {
1991
- const current = queue.shift();
1992
- const dir = await opendir2(current);
1993
- for await (const entry of dir) {
1994
- const path = join7(current, entry.name);
1995
- if (entry.isDirectory()) queue.push(path);
1996
- if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
1997
- yield path;
1998
- else yield null;
2680
+ async reconcile(prefix, issues) {
2681
+ const scan = await walk(this.ownRoot, prefix);
2682
+ const now = this.clock();
2683
+ for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
2684
+ if (scan.complete) {
2685
+ for (const prior of this.index.index()) {
2686
+ if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
2687
+ this.index.observe(prior.path, null, now);
2688
+ }
1999
2689
  }
2690
+ issues.push(...scan.issues);
2000
2691
  }
2001
- }
2002
- var SessionDiscovery = class {
2003
- constructor(state) {
2004
- this.state = state;
2005
- }
2006
- state;
2007
- scans = /* @__PURE__ */ new Map();
2008
- async cycle() {
2009
- const issues = [];
2010
- let entries = 500;
2011
- let reads = 5;
2012
- const configs = this.state.directories();
2013
- for (const [directory, scan] of this.scans) {
2014
- if (!configs.some((config) => config.directory === directory)) {
2015
- await scan.iterator.return(void 0);
2016
- this.scans.delete(directory);
2692
+ async refreshDirty(issues) {
2693
+ const blocked = /* @__PURE__ */ new Set();
2694
+ for (const prior of this.index.index(true)) {
2695
+ if (this.stopped) break;
2696
+ try {
2697
+ const current = await metadata(this.ownRoot, prior.path);
2698
+ if (current?.directory) {
2699
+ await this.reconcile(prior.path, issues);
2700
+ this.index.observe(prior.path, null, this.clock());
2701
+ } else this.index.observe(prior.path, current?.signature ?? null, this.clock());
2702
+ } catch (error) {
2703
+ blocked.add(prior.path);
2704
+ issues.push(`${prior.path}: ${message(error)}`);
2017
2705
  }
2018
2706
  }
2019
- for (const config of configs) {
2020
- let scan = this.scans.get(config.directory);
2021
- if (!scan) {
2022
- scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
2023
- this.scans.set(config.directory, scan);
2024
- }
2025
- if (scan.nextAt > Date.now()) continue;
2707
+ return blocked;
2708
+ }
2709
+ async audit(issues) {
2710
+ let bytes = 0;
2711
+ for (const prior of this.index.audit(
2712
+ this.clock() - (this.options.auditAgeMs ?? 864e5),
2713
+ this.options.auditBudget ?? 20
2714
+ )) {
2715
+ if (this.stopped || bytes > 20 * 1024 * 1024) break;
2026
2716
  try {
2027
- while (entries > 0 && reads > 0) {
2028
- entries--;
2029
- const entry = await scan.iterator.next();
2030
- if (entry.done) {
2031
- scan.iterator = candidates(config.directory, config.agent);
2032
- scan.nextAt = Date.now() + 3e5;
2033
- break;
2034
- }
2035
- const path = entry.value;
2036
- if (!path || this.state.get(path)) continue;
2037
- const signature2 = await sourceSignature(path, config.agent);
2038
- const db = this.state.state.db;
2039
- if (db.prepare(
2040
- "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
2041
- ).get(this.state.root, path, signature2))
2042
- continue;
2043
- reads--;
2044
- try {
2045
- const result = await readSource(config.agent, path, config.projectDir);
2046
- if (result.incomplete) continue;
2047
- this.state.track(config.agent, path, config.projectDir);
2048
- } catch (error) {
2049
- const message2 = error instanceof Error ? error.message : "Discovery failed.";
2050
- if (!message2.includes("outside the registered project"))
2051
- issues.push(`${path}: ${message2}`);
2052
- if (!message2.includes("outside the registered project")) continue;
2053
- }
2054
- db.prepare(
2055
- "INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
2056
- ).run(this.state.root, path, signature2);
2057
- }
2717
+ const local = await readStable(this.ownRoot, prior.path);
2718
+ bytes += local.bytes.length;
2719
+ if (local.sha256 !== prior.hash || local.signature !== prior.signature)
2720
+ this.index.event(prior.path, this.clock());
2721
+ this.index.verified(prior.path, this.clock());
2058
2722
  } catch (error) {
2059
- issues.push(
2060
- `${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
2061
- );
2062
- await scan.iterator.return(void 0);
2063
- scan.iterator = candidates(config.directory, config.agent);
2064
- scan.nextAt = Date.now() + 3e5;
2723
+ this.index.event(prior.path, this.clock());
2724
+ issues.push(`${prior.path}: ${message(error)}`);
2065
2725
  }
2066
2726
  }
2067
- return issues;
2068
- }
2069
- async close() {
2070
- for (const scan of this.scans.values()) await scan.iterator.return(void 0);
2071
- }
2072
- };
2073
-
2074
- // apps/client/sessions/synchronizer.ts
2075
- var SessionSynchronizer = class {
2076
- constructor(state, root, directory, signal) {
2077
- this.root = root;
2078
- this.directory = directory;
2079
- const binding = state.binding(root);
2080
- if (!binding) throw new Error("Connect this root before collecting sessions.");
2081
- this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
2082
- this.blobs = new LocalBlobStore(join8(directory, "blobs"));
2083
- this.index = new SessionState(state, root);
2084
- this.discovery = new SessionDiscovery(this.index);
2085
2727
  }
2086
- root;
2087
- directory;
2088
- index;
2089
- client;
2090
- blobs;
2091
- discovery;
2092
- capable = false;
2093
- async cycle() {
2094
- const report = { published: 0, checked: 0, issues: [] };
2095
- report.issues.push(...await consumeHints(this.directory, this.root, this.index));
2096
- report.issues.push(...await this.discovery.cycle());
2097
- const sources = this.index.sources(void 0, 200);
2098
- if (sources.length && !this.capable) {
2099
- const info = await this.client.serverInfo();
2100
- if (!["blobs", "session-revisions"].every(
2101
- (capability) => info.capabilities.includes(capability)
2102
- ))
2103
- throw new Error("Server does not support session publication. Upgrade the server first.");
2104
- this.capable = true;
2105
- }
2106
- let reads = 5;
2107
- let uploads = 5;
2108
- for (const source of sources) {
2109
- if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
2110
- report.checked++;
2111
- this.index.checked(source.path, source.lastError);
2728
+ async stage(blocked, issues, settleMs) {
2729
+ const ready = this.index.index(true).filter(
2730
+ (row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
2731
+ );
2732
+ if (!ready.length) return;
2733
+ const baseline = this.state.ownerFiles(this.root);
2734
+ const planned = new Map(baseline);
2735
+ const changes = [];
2736
+ const snapshots = [];
2737
+ let bytes = 0;
2738
+ ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
2739
+ for (const row of ready) {
2740
+ if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
2112
2741
  try {
2113
- if (source.pending) {
2114
- if (source.retryAt > Date.now()) {
2115
- if (source.lastError)
2116
- report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
2742
+ if (row.signature === null) {
2743
+ const current = await metadata(this.ownRoot, row.path);
2744
+ if (current && !current.directory) {
2745
+ this.index.event(row.path, this.clock());
2117
2746
  continue;
2118
2747
  }
2119
- uploads--;
2120
- await this.flush(source);
2121
- report.published++;
2748
+ if (baseline.has(row.path) && row.seenLocal) {
2749
+ changes.push({ kind: "delete", path: row.path });
2750
+ planned.delete(row.path);
2751
+ } else {
2752
+ this.index.clean(row.path, row.generation, null, this.clock());
2753
+ continue;
2754
+ }
2755
+ snapshots.push({ path: row.path, generation: row.generation, hash: null });
2122
2756
  continue;
2123
2757
  }
2124
- const signature2 = await sourceSignature(source.path, source.agent);
2125
- if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
2126
- this.index.checked(source.path);
2758
+ const key = row.path.toLowerCase();
2759
+ const collision = [...planned.keys()].some(
2760
+ (path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
2761
+ );
2762
+ if (collision) {
2763
+ issues.push(
2764
+ `${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
2765
+ );
2127
2766
  continue;
2128
2767
  }
2129
- reads--;
2130
- const observed = await readSource(source.agent, source.path, source.projectDir);
2131
- if (observed.incomplete)
2132
- throw new Error("Final session record is incomplete; waiting for the next save.");
2133
- if (!observed.snapshot.messages.length) {
2134
- this.index.checked(source.path);
2768
+ const local = await readStable(this.ownRoot, row.path);
2769
+ bytes += local.bytes.length;
2770
+ if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
2771
+ this.index.event(row.path, this.clock());
2135
2772
  continue;
2136
2773
  }
2137
- const key = sessionKey(source.agent, observed.snapshot.nativeId);
2138
- if (source.key && source.key !== key)
2139
- throw new Error(
2140
- "Native session identity changed at this path; register the new source explicitly."
2141
- );
2142
- const bytes = Buffer.from(JSON.stringify(observed.snapshot));
2143
- if (bytes.length > MAX_BLOB_BYTES)
2144
- throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
2145
- const blob = await this.blobs.put(bytes);
2146
- let revision = source.revision;
2147
- if (!source.key) {
2148
- try {
2149
- const owner = this.index.state.binding(this.root).memberId;
2150
- const current = await this.client.session(owner, key);
2151
- if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
2152
- throw new Error(
2153
- "Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
2154
- );
2155
- revision = current.revision;
2156
- } catch (error) {
2157
- if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
2158
- throw error;
2159
- }
2160
- this.index.baseline(source.path, key, revision);
2161
- }
2162
- if (blob.sha256 === source.hash) {
2163
- this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
2774
+ if (baseline.get(row.path)?.sha256 === local.sha256) {
2775
+ this.index.clean(row.path, row.generation, local.sha256, this.clock());
2164
2776
  continue;
2165
2777
  }
2166
- this.index.stage(
2167
- source.path,
2168
- { operationId: randomUUID6(), sessionKey: key, parentRevision: revision, blob },
2169
- observed.signature
2170
- );
2171
- uploads--;
2172
- await this.flush(this.index.get(source.path));
2173
- report.published++;
2778
+ const ref = await this.blobs.put(local.bytes);
2779
+ changes.push({ kind: "upsert", path: row.path, ...ref });
2780
+ planned.set(row.path, ref);
2781
+ snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
2174
2782
  } catch (error) {
2175
- const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
2176
- this.index.checked(source.path, message2);
2177
- report.issues.push(`${source.path}: ${message2}`);
2783
+ this.index.event(row.path, this.clock());
2784
+ issues.push(`${row.path}: ${message(error)}`);
2178
2785
  }
2179
2786
  }
2180
- return report;
2787
+ if (!changes.length) return;
2788
+ const rootStat = await lstat4(this.ownRoot);
2789
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
2790
+ throw new Error("Author directory disappeared before publication.");
2791
+ this.state.enqueue(this.root, {
2792
+ operationId: randomUUID6(),
2793
+ parentRevision: this.state.publication(this.root).revision,
2794
+ changes
2795
+ });
2796
+ for (const snapshot of snapshots)
2797
+ this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
2181
2798
  }
2182
- async flush(source) {
2183
- const request = source.pending;
2184
- try {
2185
- const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
2186
- if (missing2.some((hash2) => hash2 !== request.blob.sha256))
2187
- throw new Error("Server requested unrelated session content.");
2188
- if (missing2.length) {
2189
- const bytes = await this.blobs.get(request.blob.sha256);
2190
- if (!bytes) throw new Error("Queued session snapshot is missing.");
2191
- const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
2192
- if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
2193
- throw new Error("Server acknowledged different session content.");
2799
+ async receive(owner) {
2800
+ if (!this.index.job(owner)) {
2801
+ const revision = this.index.revision(owner);
2802
+ if (revision === void 0) {
2803
+ const manifest = await this.manifest(owner);
2804
+ this.index.queue(
2805
+ owner,
2806
+ manifest.revision,
2807
+ manifest.files.map((file) => ({
2808
+ kind: "upsert",
2809
+ path: file.path,
2810
+ sha256: file.sha256,
2811
+ size: file.size
2812
+ }))
2813
+ );
2814
+ } else {
2815
+ const delta = await this.client.fileChanges(owner, revision);
2816
+ if (!delta.revisions.length) return 0;
2817
+ const changes = /* @__PURE__ */ new Map();
2818
+ let expected = revision + 1;
2819
+ for (const entry of delta.revisions) {
2820
+ if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
2821
+ for (const change of entry.changes) changes.set(change.path, change);
2822
+ }
2823
+ const finalNames = new Set(
2824
+ [...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
2825
+ );
2826
+ for (const change of changes.values()) {
2827
+ if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
2828
+ changes.delete(change.path);
2829
+ }
2830
+ const ordered = [...changes.values()].sort(
2831
+ (a, b) => a.kind !== b.kind ? a.kind === "delete" ? -1 : 1 : a.kind === "delete" ? b.path.length - a.path.length : a.path.length - b.path.length
2832
+ );
2833
+ this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
2194
2834
  }
2195
- const result = await this.client.commitSession(request);
2196
- if (result.operationId !== request.operationId || result.revision < request.parentRevision || result.revision > request.parentRevision + 1)
2197
- throw new Error("Invalid session acknowledgement.");
2198
- this.index.acknowledge(
2199
- source.path,
2200
- result.revision,
2201
- request.blob.sha256,
2202
- source.pendingSignature
2203
- );
2204
- } catch (error) {
2205
- const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2206
- this.index.postpone(
2207
- source,
2208
- error instanceof Error ? error.message : "Upload failed.",
2209
- blocked
2835
+ }
2836
+ const job = this.index.job(owner);
2837
+ const ownerRoot = await safePath(this.root, owner);
2838
+ await mkdir9(ownerRoot).catch((error) => {
2839
+ if (error.code !== "EEXIST") throw error;
2840
+ });
2841
+ const ownerStat = await lstat4(ownerRoot);
2842
+ if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
2843
+ throw new Error("Mirror root is not an ordinary directory.");
2844
+ let count = 0;
2845
+ let bytes = 0;
2846
+ for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
2847
+ if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
2848
+ break;
2849
+ const change = job.changes[offset];
2850
+ await this.apply(owner, ownerRoot, change);
2851
+ this.index.applied(owner, change, offset + 1);
2852
+ count++;
2853
+ if (change.kind === "upsert") bytes += change.size;
2854
+ }
2855
+ if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
2856
+ return count;
2857
+ }
2858
+ async apply(owner, root, change) {
2859
+ const prior = this.index.received(owner, change.path);
2860
+ const current = await metadata(root, change.path);
2861
+ let local;
2862
+ if (current && !current.directory) local = await readStable(root, change.path);
2863
+ if (change.kind === "delete") {
2864
+ if (!current) return;
2865
+ if (current.directory && !prior) return;
2866
+ if (!local || !prior || local.sha256 !== prior.sha256)
2867
+ throw new Error(`Local mirror differs; preserving ${change.path}.`);
2868
+ await removeFile(root, change.path, local.signature);
2869
+ } else {
2870
+ if (local?.sha256 === change.sha256) return;
2871
+ if (local && (!prior || local.sha256 !== prior.sha256))
2872
+ throw new Error(`Local mirror differs; preserving ${change.path}.`);
2873
+ if (!current && prior && !this.options.restoreMissing)
2874
+ throw new Error(
2875
+ `Local mirror was removed; use --restore-missing to restore ${change.path}.`
2876
+ );
2877
+ let content = await this.blobs.get(change.sha256);
2878
+ if (!content) {
2879
+ content = await this.client.downloadBlob(change.sha256);
2880
+ await this.blobs.put(content);
2881
+ }
2882
+ if (content.byteLength !== change.size)
2883
+ throw new Error(`Content size mismatch: ${change.path}`);
2884
+ await replaceFile(root, change.path, content, local?.signature ?? null);
2885
+ }
2886
+ }
2887
+ };
2888
+ function message(error) {
2889
+ return diagnostic(error);
2890
+ }
2891
+
2892
+ // apps/client/sessions/synchronizer.ts
2893
+ import { randomUUID as randomUUID7 } from "node:crypto";
2894
+ import { join as join14 } from "node:path";
2895
+
2896
+ // packages/protocol/session-key.ts
2897
+ import { createHash as createHash9 } from "node:crypto";
2898
+ function sessionKey(agent, nativeId) {
2899
+ return createHash9("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
2900
+ }
2901
+
2902
+ // apps/client/sessions/source.ts
2903
+ import { open as open4, stat, realpath as realpath5 } from "node:fs/promises";
2904
+ import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
2905
+ import * as zlib from "node:zlib";
2906
+
2907
+ // apps/client/sessions/cursor.ts
2908
+ import { createHash as createHash10 } from "node:crypto";
2909
+ import { basename as basename2, dirname as dirname7, resolve as resolve7 } from "node:path";
2910
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
2911
+ import { fileURLToPath } from "node:url";
2912
+ function fields(bytes) {
2913
+ const data = Buffer.from(bytes);
2914
+ let offset = 0;
2915
+ const varint = () => {
2916
+ let value = 0n;
2917
+ for (let i = 0; i < 10; i++) {
2918
+ if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
2919
+ const byte = data[offset++];
2920
+ value |= BigInt(byte & 127) << BigInt(i * 7);
2921
+ if (byte < 128) return value;
2922
+ }
2923
+ throw new Error("Invalid Cursor protobuf integer.");
2924
+ };
2925
+ const result2 = /* @__PURE__ */ new Map();
2926
+ while (offset < data.length) {
2927
+ const tag = varint();
2928
+ const field = Number(tag >> 3n);
2929
+ if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
2930
+ const wire = Number(tag & 7n);
2931
+ if (wire === 0) varint();
2932
+ else if (wire === 1) offset += 8;
2933
+ else if (wire === 5) offset += 4;
2934
+ else if (wire === 2) {
2935
+ const length = Number(varint());
2936
+ if (!Number.isSafeInteger(length) || length > data.length - offset)
2937
+ throw new Error("Truncated Cursor protobuf value.");
2938
+ const values = result2.get(field) ?? [];
2939
+ values.push(data.subarray(offset, offset + length));
2940
+ result2.set(field, values);
2941
+ offset += length;
2942
+ } else throw new Error("Unsupported Cursor protobuf wire type.");
2943
+ if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
2944
+ }
2945
+ return result2;
2946
+ }
2947
+ function readCursor(path, projectDir) {
2948
+ const db = new DatabaseSync2(path, { readOnly: true });
2949
+ try {
2950
+ db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
2951
+ if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
2952
+ throw new Error("Unsupported Cursor CLI store version (expected 1).");
2953
+ const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
2954
+ const hex = row?.value;
2955
+ if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
2956
+ throw new Error("Invalid Cursor metadata.");
2957
+ const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
2958
+ if (typeof meta.agentId !== "string" || meta.agentId !== basename2(dirname7(path)) || meta.subagentInfo != null)
2959
+ throw new Error("Invalid or delegated Cursor session identity.");
2960
+ let budget = 64 * 1024 * 1024;
2961
+ const blob = (id) => {
2962
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
2963
+ const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
2964
+ if (!(value instanceof Uint8Array))
2965
+ throw new Error("Cursor history missing or exceeds 64 MiB.");
2966
+ const bytes = Buffer.from(value);
2967
+ budget -= bytes.length;
2968
+ if (createHash10("sha256").update(bytes).digest("hex") !== id)
2969
+ throw new Error("Cursor blob failed integrity verification.");
2970
+ return bytes;
2971
+ };
2972
+ const root = fields(blob(String(meta.latestRootBlobId)));
2973
+ const bucket = basename2(dirname7(dirname7(path)));
2974
+ const md5 = (cwd2) => createHash10("md5").update(resolve7(cwd2)).digest("hex");
2975
+ let cwd = md5(projectDir) === bucket ? projectDir : void 0;
2976
+ for (const uri of root.get(9) ?? []) {
2977
+ try {
2978
+ const native = fileURLToPath(uri.toString("utf8"));
2979
+ if (md5(native) === bucket) {
2980
+ cwd = native;
2981
+ break;
2982
+ }
2983
+ } catch {
2984
+ }
2985
+ }
2986
+ if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
2987
+ const refs = [];
2988
+ for (const archive of root.get(13) ?? root.get(11) ?? [])
2989
+ refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
2990
+ refs.push(...root.get(1) ?? []);
2991
+ if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
2992
+ const messages = [];
2993
+ const counts = /* @__PURE__ */ new Map();
2994
+ for (const ref of refs) {
2995
+ const key = ref.toString("hex");
2996
+ const message2 = object(
2997
+ JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
2210
2998
  );
2211
- throw error;
2999
+ const cursor = object(object(message2.providerOptions).cursor);
3000
+ if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
3001
+ let text = textContent(message2.content);
3002
+ if (message2.role === "user") {
3003
+ if (!cursor.requestId) continue;
3004
+ const start = text.indexOf("<user_query>");
3005
+ const end = text.lastIndexOf("</user_query>");
3006
+ if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
3007
+ }
3008
+ if (!text.trim()) continue;
3009
+ const id = createHash10("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
3010
+ const count = (counts.get(id) ?? 0) + 1;
3011
+ counts.set(id, count);
3012
+ messages.push({
3013
+ id: `${id}#${count}`,
3014
+ role: message2.role,
3015
+ text,
3016
+ source: `blob:${key}`
3017
+ });
2212
3018
  }
3019
+ return SessionSnapshotSchema.parse({
3020
+ formatVersion: 1,
3021
+ adapterVersion: 1,
3022
+ agent: "cursor",
3023
+ nativeId: meta.agentId,
3024
+ cwd,
3025
+ messages
3026
+ });
3027
+ } finally {
3028
+ db.close();
2213
3029
  }
2214
- async close() {
2215
- await this.discovery.close();
2216
- }
2217
- };
2218
-
2219
- // apps/client/files/synchronizer.ts
2220
- import { randomUUID as randomUUID7 } from "node:crypto";
2221
- import { watch } from "node:fs";
2222
- import { lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
2223
- import { join as join9, sep as sep4 } from "node:path";
2224
- import { performance } from "node:perf_hooks";
3030
+ }
2225
3031
 
2226
- // apps/client/publication.ts
2227
- var PublicationQueue = class {
2228
- constructor(state, blobs, transport) {
2229
- this.state = state;
2230
- this.blobs = blobs;
2231
- this.transport = transport;
2232
- }
2233
- state;
2234
- blobs;
2235
- transport;
2236
- async flush(root, now = Date.now()) {
2237
- const pending = this.state.pending(root);
2238
- if (!pending || pending.retryAt > now) return false;
3032
+ // apps/client/sessions/source.ts
3033
+ var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
3034
+ async function sourceSignature(path, agent) {
3035
+ const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
3036
+ const signatures = [];
3037
+ for (const file of paths) {
2239
3038
  try {
2240
- const hashes = [
2241
- ...new Set(
2242
- pending.batch.changes.flatMap(
2243
- (change) => change.kind === "upsert" ? [change.sha256] : []
2244
- )
2245
- )
2246
- ];
2247
- const { missing: missing2 } = await this.transport.missingBlobs(hashes);
2248
- for (const hash2 of missing2) {
2249
- if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
2250
- const content = await this.blobs.get(hash2);
2251
- if (!content) throw new Error("A queued content snapshot is missing from local storage.");
2252
- const result = await this.transport.uploadBlob(hash2, content);
2253
- if (result.sha256 !== hash2 || result.size !== content.byteLength)
2254
- throw new Error("Server acknowledged different content.");
2255
- }
2256
- this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
2257
- return true;
3039
+ const s = await stat(file, { bigint: true });
3040
+ if (!s.isFile()) throw new Error("Session source must be a regular file.");
3041
+ signatures.push([
3042
+ String(s.dev),
3043
+ String(s.ino),
3044
+ String(s.size),
3045
+ String(s.mtimeNs),
3046
+ String(s.ctimeNs)
3047
+ ]);
2258
3048
  } catch (error) {
2259
- const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2260
- this.state.postpone(
2261
- root,
2262
- pending.batch.operationId,
2263
- error instanceof Error ? error.message : "Publication failed.",
2264
- now,
2265
- blocked
2266
- );
2267
- throw error;
3049
+ if (file !== path && error.code === "ENOENT")
3050
+ signatures.push(null);
3051
+ else throw error;
2268
3052
  }
2269
3053
  }
2270
- };
2271
-
2272
- // apps/client/files/synchronizer.ts
2273
- var FileSynchronizer = class {
2274
- constructor(state, root, stateDirectory, options = {}) {
2275
- this.state = state;
2276
- this.root = root;
2277
- this.options = options;
2278
- const binding = state.binding(root);
2279
- if (!binding) throw new Error("Connect this root before synchronizing.");
2280
- this.binding = binding;
2281
- this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2282
- this.blobs = new LocalBlobStore(join9(stateDirectory, "blobs"));
2283
- this.queue = new PublicationQueue(state, this.blobs, this.client);
2284
- this.index = new SyncState(state, root);
2285
- this.ownRoot = join9(root, binding.memberId);
2286
- const started = performance.now();
2287
- const epoch = Date.now();
2288
- this.clock = options.clock ?? (() => epoch + performance.now() - started);
2289
- this.settleMs = options.settleMs ?? 6e4;
2290
- this.index.restartWaiting(this.clock());
2291
- }
2292
- state;
2293
- root;
2294
- options;
2295
- index;
2296
- binding;
2297
- client;
2298
- blobs;
2299
- queue;
2300
- ownRoot;
2301
- clock;
2302
- settleMs;
2303
- watcher;
2304
- watchIssues = [];
2305
- lastScan = -Infinity;
2306
- busy = false;
2307
- stopped = false;
2308
- abort = new AbortController();
2309
- async checkCapabilities() {
2310
- const info = await this.client.serverInfo();
2311
- if (!["identity", "blobs", "file-revisions"].every(
2312
- (capability) => info.capabilities.includes(capability)
2313
- ))
2314
- throw new Error("Server does not support file synchronization.");
2315
- }
2316
- setSettleMs(value) {
2317
- if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
2318
- throw new Error("Invalid stable wait interval.");
2319
- this.settleMs = value;
2320
- }
2321
- close() {
2322
- this.stopped = true;
2323
- this.abort.abort();
2324
- this.watcher?.close();
2325
- this.watcher = void 0;
2326
- }
2327
- async cycle() {
2328
- if (this.busy || this.stopped) throw new Error("Synchronizer is already running or closed.");
2329
- this.busy = true;
2330
- const settleMs = this.settleMs;
2331
- const report = {
2332
- published: false,
2333
- appliedFiles: 0,
2334
- waitingFiles: 0,
2335
- pendingPublication: false,
2336
- issues: this.watchIssues.splice(0)
2337
- };
3054
+ return JSON.stringify(signatures);
3055
+ }
3056
+ async function readSource(agent, path, projectDir) {
3057
+ const signature2 = await sourceSignature(path, agent);
3058
+ let result2;
3059
+ if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
3060
+ else {
3061
+ const handle = await open4(path, "r");
3062
+ let bytes;
2338
3063
  try {
2339
- try {
2340
- await this.initializeAuthor();
2341
- const ownStat = await lstat2(await safePath(this.root, this.binding.memberId));
2342
- if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
2343
- throw new Error("Author directory is unavailable or is a symlink.");
2344
- this.listen();
2345
- if (this.clock() - this.lastScan >= (this.options.reconcileMs ?? 3e5)) {
2346
- await this.reconcile("", report.issues);
2347
- this.lastScan = this.clock();
2348
- }
2349
- const blocked = await this.refreshDirty(report.issues);
2350
- await this.audit(report.issues);
2351
- report.published = await this.queue.flush(this.root, this.clock());
2352
- if (!this.state.pending(this.root) && !this.stopped) {
2353
- await this.stage(blocked, report.issues, settleMs);
2354
- report.published = await this.queue.flush(this.root, this.clock()) || report.published;
2355
- }
2356
- } catch (error) {
2357
- report.issues.push(`publish: ${message(error)}`);
2358
- }
2359
- if (!this.stopped) {
2360
- try {
2361
- for (const member of (await this.client.members()).members) {
2362
- if (member === this.binding.memberId || this.stopped) continue;
2363
- try {
2364
- report.appliedFiles += await this.receive(member);
2365
- } catch (error) {
2366
- report.issues.push(`${member}: ${message(error)}`);
2367
- }
2368
- }
2369
- } catch (error) {
2370
- report.issues.push(`receive: ${message(error)}`);
2371
- }
3064
+ const size = (await handle.stat()).size;
3065
+ if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
3066
+ bytes = Buffer.alloc(size + 1);
3067
+ let offset = 0;
3068
+ while (offset < bytes.length) {
3069
+ const read = await handle.read(bytes, offset, bytes.length - offset, offset);
3070
+ if (!read.bytesRead) break;
3071
+ offset += read.bytesRead;
2372
3072
  }
2373
- report.waitingFiles = this.index.index(true).length;
2374
- report.pendingPublication = Boolean(this.state.pending(this.root));
2375
- this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
2376
- return report;
3073
+ bytes = bytes.subarray(0, offset);
3074
+ if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
2377
3075
  } finally {
2378
- this.busy = false;
2379
- }
2380
- }
2381
- async manifest(owner) {
2382
- let revision;
2383
- let after = "";
2384
- const files = [];
2385
- for (; ; ) {
2386
- const page = await this.client.fileManifest(owner, revision, after);
2387
- if (revision !== void 0 && page.revision !== revision)
2388
- throw new Error("Manifest changed version during pagination.");
2389
- revision = page.revision;
2390
- files.push(...page.files);
2391
- if (page.nextCursor === null) return { revision, files };
2392
- if (page.nextCursor === after || files.length > 1e5)
2393
- throw new Error("Manifest pagination did not make bounded progress.");
2394
- after = page.nextCursor;
3076
+ await handle.close();
2395
3077
  }
2396
- }
2397
- async initializeAuthor() {
2398
- if (this.state.publication(this.root).initialized) return;
2399
- const manifest = await this.manifest(this.binding.memberId);
2400
- if (manifest.revision > 0) {
2401
- if (!this.options.adoptBaseline)
3078
+ if (agent === "dsh" && path.endsWith(".zstd")) {
3079
+ const decompress = zlib.zstdDecompressSync;
3080
+ if (!decompress)
2402
3081
  throw new Error(
2403
- "Published history exists but the local baseline is missing. Verify local files, then use --adopt-baseline."
3082
+ "Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
2404
3083
  );
2405
- for (const file of manifest.files) {
2406
- const local = await readStable(this.ownRoot, file.path);
2407
- if (local.sha256 !== file.sha256)
2408
- throw new Error(
2409
- `Cannot adopt baseline: local ${file.path} differs from the published file.`
2410
- );
2411
- }
2412
- } else {
2413
- const ownerPath = await safePath(this.root, this.binding.memberId);
2414
- await mkdir6(ownerPath).catch((error) => {
2415
- if (error.code !== "EEXIST") throw error;
2416
- });
3084
+ bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
2417
3085
  }
2418
- this.state.initializePublication(this.root, manifest.revision, manifest.files);
3086
+ result2 = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
2419
3087
  }
2420
- listen() {
2421
- if (this.options.watch === false || this.watcher || this.stopped) return;
2422
- try {
2423
- this.watcher = watch(this.ownRoot, { recursive: true }, (_event, filename) => {
2424
- if (!filename) {
2425
- this.lastScan = -Infinity;
2426
- return;
2427
- }
2428
- const path = filename.toString().split(sep4).join("/");
2429
- if (!ignored(path)) this.index.event(path, this.clock());
2430
- });
2431
- this.watcher.on("error", (error) => {
2432
- this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
2433
- this.watcher?.close();
2434
- this.watcher = void 0;
2435
- this.lastScan = -Infinity;
2436
- });
2437
- } catch (error) {
2438
- this.watchIssues.push(`File watcher unavailable: ${message(error)}; using metadata scans.`);
3088
+ if (signature2 !== await sourceSignature(path, agent))
3089
+ throw new Error("Session source changed while reading; retry next cycle.");
3090
+ const cwd = await realpath5(result2.snapshot.cwd);
3091
+ const project = await realpath5(projectDir);
3092
+ const rel = relative4(project, cwd);
3093
+ if (rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute4(rel))
3094
+ throw new Error("Session cwd is outside the registered project.");
3095
+ return { ...result2, signature: signature2 };
3096
+ }
3097
+
3098
+ // apps/client/sessions/discovery.ts
3099
+ import { opendir as opendir2 } from "node:fs/promises";
3100
+ import { join as join13 } from "node:path";
3101
+ async function* candidates(directory, agent) {
3102
+ const queue = [directory];
3103
+ while (queue.length) {
3104
+ const current = queue.shift();
3105
+ const dir = await opendir2(current);
3106
+ for await (const entry of dir) {
3107
+ const path = join13(current, entry.name);
3108
+ if (entry.isDirectory()) queue.push(path);
3109
+ if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
3110
+ yield path;
3111
+ else yield null;
2439
3112
  }
2440
3113
  }
2441
- async reconcile(prefix, issues) {
2442
- const scan = await walk(this.ownRoot, prefix);
2443
- const now = this.clock();
2444
- for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
2445
- if (scan.complete) {
2446
- for (const prior of this.index.index()) {
2447
- if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
2448
- this.index.observe(prior.path, null, now);
2449
- }
2450
- }
2451
- issues.push(...scan.issues);
3114
+ }
3115
+ var SessionDiscovery = class {
3116
+ constructor(state) {
3117
+ this.state = state;
2452
3118
  }
2453
- async refreshDirty(issues) {
2454
- const blocked = /* @__PURE__ */ new Set();
2455
- for (const prior of this.index.index(true)) {
2456
- if (this.stopped) break;
2457
- try {
2458
- const current = await metadata(this.ownRoot, prior.path);
2459
- if (current?.directory) {
2460
- await this.reconcile(prior.path, issues);
2461
- this.index.observe(prior.path, null, this.clock());
2462
- } else this.index.observe(prior.path, current?.signature ?? null, this.clock());
2463
- } catch (error) {
2464
- blocked.add(prior.path);
2465
- issues.push(`${prior.path}: ${message(error)}`);
3119
+ state;
3120
+ scans = /* @__PURE__ */ new Map();
3121
+ async cycle() {
3122
+ const issues = [];
3123
+ let entries = 500;
3124
+ let reads = 5;
3125
+ const configs = this.state.directories();
3126
+ for (const [directory, scan] of this.scans) {
3127
+ if (!configs.some((config) => config.directory === directory)) {
3128
+ await scan.iterator.return(void 0);
3129
+ this.scans.delete(directory);
2466
3130
  }
2467
3131
  }
2468
- return blocked;
2469
- }
2470
- async audit(issues) {
2471
- let bytes = 0;
2472
- for (const prior of this.index.audit(
2473
- this.clock() - (this.options.auditAgeMs ?? 864e5),
2474
- this.options.auditBudget ?? 20
2475
- )) {
2476
- if (this.stopped || bytes > 20 * 1024 * 1024) break;
3132
+ for (const config of configs) {
3133
+ let scan = this.scans.get(config.directory);
3134
+ if (!scan) {
3135
+ scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
3136
+ this.scans.set(config.directory, scan);
3137
+ }
3138
+ if (scan.nextAt > Date.now()) continue;
2477
3139
  try {
2478
- const local = await readStable(this.ownRoot, prior.path);
2479
- bytes += local.bytes.length;
2480
- if (local.sha256 !== prior.hash || local.signature !== prior.signature)
2481
- this.index.event(prior.path, this.clock());
2482
- this.index.verified(prior.path, this.clock());
3140
+ while (entries > 0 && reads > 0) {
3141
+ entries--;
3142
+ const entry = await scan.iterator.next();
3143
+ if (entry.done) {
3144
+ scan.iterator = candidates(config.directory, config.agent);
3145
+ scan.nextAt = Date.now() + 3e5;
3146
+ break;
3147
+ }
3148
+ const path = entry.value;
3149
+ if (!path || this.state.get(path)) continue;
3150
+ const signature2 = await sourceSignature(path, config.agent);
3151
+ const db = this.state.state.db;
3152
+ if (db.prepare(
3153
+ "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
3154
+ ).get(this.state.root, path, signature2))
3155
+ continue;
3156
+ reads--;
3157
+ try {
3158
+ const result2 = await readSource(config.agent, path, config.projectDir);
3159
+ if (result2.incomplete) continue;
3160
+ this.state.track(config.agent, path, config.projectDir);
3161
+ } catch (error) {
3162
+ const message2 = error instanceof Error ? error.message : "Discovery failed.";
3163
+ if (!message2.includes("outside the registered project"))
3164
+ issues.push(`${path}: ${message2}`);
3165
+ if (!message2.includes("outside the registered project")) continue;
3166
+ }
3167
+ db.prepare(
3168
+ "INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
3169
+ ).run(this.state.root, path, signature2);
3170
+ }
2483
3171
  } catch (error) {
2484
- this.index.event(prior.path, this.clock());
2485
- issues.push(`${prior.path}: ${message(error)}`);
3172
+ issues.push(
3173
+ `${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
3174
+ );
3175
+ await scan.iterator.return(void 0);
3176
+ scan.iterator = candidates(config.directory, config.agent);
3177
+ scan.nextAt = Date.now() + 3e5;
2486
3178
  }
2487
3179
  }
3180
+ return issues;
2488
3181
  }
2489
- async stage(blocked, issues, settleMs) {
2490
- const ready = this.index.index(true).filter(
2491
- (row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
2492
- );
2493
- if (!ready.length) return;
2494
- const baseline = this.state.ownerFiles(this.root);
2495
- const planned = new Map(baseline);
2496
- const changes = [];
2497
- const snapshots = [];
2498
- let bytes = 0;
2499
- ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
2500
- for (const row of ready) {
2501
- if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
3182
+ async close() {
3183
+ for (const scan of this.scans.values()) await scan.iterator.return(void 0);
3184
+ }
3185
+ };
3186
+
3187
+ // apps/client/sessions/synchronizer.ts
3188
+ var SessionSynchronizer = class {
3189
+ constructor(state, root, directory, signal) {
3190
+ this.root = root;
3191
+ this.directory = directory;
3192
+ const binding = state.binding(root);
3193
+ if (!binding) throw new Error("Connect this root before collecting sessions.");
3194
+ this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
3195
+ this.blobs = new LocalBlobStore(join14(directory, "blobs"));
3196
+ this.index = new SessionState(state, root);
3197
+ this.discovery = new SessionDiscovery(this.index);
3198
+ }
3199
+ root;
3200
+ directory;
3201
+ index;
3202
+ client;
3203
+ blobs;
3204
+ discovery;
3205
+ capable = false;
3206
+ async cycle() {
3207
+ const report = { published: 0, checked: 0, issues: [] };
3208
+ report.issues.push(...await consumeHints(this.directory, this.root, this.index));
3209
+ report.issues.push(...await this.discovery.cycle());
3210
+ const sources = this.index.sources(void 0, 200);
3211
+ if (sources.length && !this.capable) {
3212
+ const info = await this.client.serverInfo();
3213
+ if (!["blobs", "session-revisions"].every(
3214
+ (capability) => info.capabilities.includes(capability)
3215
+ ))
3216
+ throw new Error("Server does not support session publication. Upgrade the server first.");
3217
+ this.capable = true;
3218
+ }
3219
+ let reads = 5;
3220
+ let uploads = 5;
3221
+ for (const source of sources) {
3222
+ if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
3223
+ report.checked++;
3224
+ this.index.checked(source.path, source.lastError);
2502
3225
  try {
2503
- if (row.signature === null) {
2504
- const current = await metadata(this.ownRoot, row.path);
2505
- if (current && !current.directory) {
2506
- this.index.event(row.path, this.clock());
2507
- continue;
2508
- }
2509
- if (baseline.has(row.path)) {
2510
- changes.push({ kind: "delete", path: row.path });
2511
- planned.delete(row.path);
2512
- } else {
2513
- this.index.clean(row.path, row.generation, null, this.clock());
3226
+ if (source.pending) {
3227
+ if (source.retryAt > Date.now()) {
3228
+ if (source.lastError)
3229
+ report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
2514
3230
  continue;
2515
3231
  }
2516
- snapshots.push({ path: row.path, generation: row.generation, hash: null });
3232
+ uploads--;
3233
+ await this.flush(source);
3234
+ report.published++;
2517
3235
  continue;
2518
3236
  }
2519
- const key = row.path.toLowerCase();
2520
- const collision = [...planned.keys()].some(
2521
- (path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
2522
- );
2523
- if (collision) continue;
2524
- const local = await readStable(this.ownRoot, row.path);
2525
- bytes += local.bytes.length;
2526
- if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
2527
- this.index.event(row.path, this.clock());
3237
+ const signature2 = await sourceSignature(source.path, source.agent);
3238
+ if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
3239
+ this.index.checked(source.path);
2528
3240
  continue;
2529
3241
  }
2530
- if (baseline.get(row.path)?.sha256 === local.sha256) {
2531
- this.index.clean(row.path, row.generation, local.sha256, this.clock());
3242
+ reads--;
3243
+ const observed = await readSource(source.agent, source.path, source.projectDir);
3244
+ if (observed.incomplete)
3245
+ throw new Error("Final session record is incomplete; waiting for the next save.");
3246
+ if (!observed.snapshot.messages.length) {
3247
+ this.index.checked(source.path);
3248
+ continue;
3249
+ }
3250
+ const key = sessionKey(source.agent, observed.snapshot.nativeId);
3251
+ if (source.key && source.key !== key)
3252
+ throw new Error(
3253
+ "Native session identity changed at this path; register the new source explicitly."
3254
+ );
3255
+ const bytes = Buffer.from(JSON.stringify(observed.snapshot));
3256
+ if (bytes.length > MAX_BLOB_BYTES)
3257
+ throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
3258
+ const blob = await this.blobs.put(bytes);
3259
+ let revision = source.revision;
3260
+ if (!source.key) {
3261
+ try {
3262
+ const owner = this.index.state.binding(this.root).memberId;
3263
+ const current = await this.client.session(owner, key);
3264
+ if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
3265
+ throw new Error(
3266
+ "Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
3267
+ );
3268
+ revision = current.revision;
3269
+ } catch (error) {
3270
+ if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
3271
+ throw error;
3272
+ }
3273
+ this.index.baseline(source.path, key, revision);
3274
+ }
3275
+ if (blob.sha256 === source.hash) {
3276
+ this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
2532
3277
  continue;
2533
3278
  }
2534
- const ref = await this.blobs.put(local.bytes);
2535
- changes.push({ kind: "upsert", path: row.path, ...ref });
2536
- planned.set(row.path, ref);
2537
- snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
3279
+ this.index.stage(
3280
+ source.path,
3281
+ { operationId: randomUUID7(), sessionKey: key, parentRevision: revision, blob },
3282
+ observed.signature
3283
+ );
3284
+ uploads--;
3285
+ await this.flush(this.index.get(source.path));
3286
+ report.published++;
2538
3287
  } catch (error) {
2539
- this.index.event(row.path, this.clock());
2540
- issues.push(`${row.path}: ${message(error)}`);
3288
+ const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
3289
+ this.index.checked(source.path, message2);
3290
+ report.issues.push(`${source.path}: ${message2}`);
2541
3291
  }
2542
3292
  }
2543
- if (!changes.length) return;
2544
- const rootStat = await lstat2(this.ownRoot);
2545
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
2546
- throw new Error("Author directory disappeared before publication.");
2547
- this.state.enqueue(this.root, {
2548
- operationId: randomUUID7(),
2549
- parentRevision: this.state.publication(this.root).revision,
2550
- changes
2551
- });
2552
- for (const snapshot of snapshots)
2553
- this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
3293
+ return report;
2554
3294
  }
2555
- async receive(owner) {
2556
- if (!this.index.job(owner)) {
2557
- const revision = this.index.revision(owner);
2558
- if (revision === void 0) {
2559
- const manifest = await this.manifest(owner);
2560
- this.index.queue(
2561
- owner,
2562
- manifest.revision,
2563
- manifest.files.map((file) => ({
2564
- kind: "upsert",
2565
- path: file.path,
2566
- sha256: file.sha256,
2567
- size: file.size
2568
- }))
2569
- );
2570
- } else {
2571
- const delta = await this.client.fileChanges(owner, revision);
2572
- if (!delta.revisions.length) return 0;
2573
- const changes = /* @__PURE__ */ new Map();
2574
- let expected = revision + 1;
2575
- for (const entry of delta.revisions) {
2576
- if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
2577
- for (const change of entry.changes) changes.set(change.path, change);
2578
- }
2579
- const finalNames = new Set(
2580
- [...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
2581
- );
2582
- for (const change of changes.values()) {
2583
- if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
2584
- changes.delete(change.path);
2585
- }
2586
- const ordered = [...changes.values()].sort(
2587
- (a, b) => a.kind !== b.kind ? a.kind === "delete" ? -1 : 1 : a.kind === "delete" ? b.path.length - a.path.length : a.path.length - b.path.length
2588
- );
2589
- this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
3295
+ async flush(source) {
3296
+ const request = source.pending;
3297
+ try {
3298
+ const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
3299
+ if (missing2.some((hash2) => hash2 !== request.blob.sha256))
3300
+ throw new Error("Server requested unrelated session content.");
3301
+ if (missing2.length) {
3302
+ const bytes = await this.blobs.get(request.blob.sha256);
3303
+ if (!bytes) throw new Error("Queued session snapshot is missing.");
3304
+ const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
3305
+ if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
3306
+ throw new Error("Server acknowledged different session content.");
2590
3307
  }
3308
+ const result2 = await this.client.commitSession(request);
3309
+ if (result2.operationId !== request.operationId || result2.revision < request.parentRevision || result2.revision > request.parentRevision + 1)
3310
+ throw new Error("Invalid session acknowledgement.");
3311
+ this.index.acknowledge(
3312
+ source.path,
3313
+ result2.revision,
3314
+ request.blob.sha256,
3315
+ source.pendingSignature
3316
+ );
3317
+ } catch (error) {
3318
+ const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
3319
+ this.index.postpone(
3320
+ source,
3321
+ error instanceof Error ? error.message : "Upload failed.",
3322
+ blocked
3323
+ );
3324
+ throw error;
2591
3325
  }
2592
- const job = this.index.job(owner);
2593
- const ownerRoot = await safePath(this.root, owner);
2594
- await mkdir6(ownerRoot).catch((error) => {
2595
- if (error.code !== "EEXIST") throw error;
3326
+ }
3327
+ async close() {
3328
+ await this.discovery.close();
3329
+ }
3330
+ };
3331
+
3332
+ // apps/client/runtime/job.ts
3333
+ var SyncJob = class {
3334
+ constructor(root, directory, version, log, options = {}) {
3335
+ this.root = root;
3336
+ this.log = log;
3337
+ this.lock = acquireRootLock(root, directory);
3338
+ let state;
3339
+ try {
3340
+ state = new ClientState(directory);
3341
+ this.state = state;
3342
+ this.runtime = new RuntimeState(state);
3343
+ this.files = options.sessionsOnly ? void 0 : new FileSynchronizer(state, root, directory, {
3344
+ ...options.settleMs === void 0 ? {} : { settleMs: options.settleMs },
3345
+ adoptBaseline: Boolean(options.adoptBaseline),
3346
+ restoreMissing: Boolean(options.restoreMissing)
3347
+ });
3348
+ this.sessions = new SessionSynchronizer(state, root, directory, this.abort.signal);
3349
+ if (options.retry) {
3350
+ state.retryPublication(root);
3351
+ this.sessions.index.retry();
3352
+ }
3353
+ const overrides = {};
3354
+ if (options.intervalMs !== void 0)
3355
+ overrides.checkIntervalSeconds = options.intervalMs / 1e3;
3356
+ if (options.settleMs !== void 0) overrides.fileSettleSeconds = options.settleMs / 1e3;
3357
+ this.control = new ControlPlane(state, root, version, overrides, (policy) => {
3358
+ this.intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
3359
+ this.files?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
3360
+ });
3361
+ } catch (e) {
3362
+ state?.close();
3363
+ this.lock.close();
3364
+ throw e;
3365
+ }
3366
+ }
3367
+ root;
3368
+ log;
3369
+ state;
3370
+ runtime;
3371
+ control;
3372
+ files;
3373
+ sessions;
3374
+ lock;
3375
+ abort = new AbortController();
3376
+ pending;
3377
+ stopped = false;
3378
+ closing;
3379
+ controlStarted = false;
3380
+ lastIssue = "";
3381
+ issueCount = 0;
3382
+ lastLogAt = 0;
3383
+ nextAt = 0;
3384
+ intervalMs = 6e4;
3385
+ tick() {
3386
+ if (this.stopped) return Promise.reject(new Error("Synchronization stopped."));
3387
+ this.pending ??= this.cycle().finally(() => {
3388
+ this.pending = void 0;
2596
3389
  });
2597
- const ownerStat = await lstat2(ownerRoot);
2598
- if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
2599
- throw new Error("Mirror root is not an ordinary directory.");
2600
- let count = 0;
2601
- let bytes = 0;
2602
- for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
2603
- if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
2604
- break;
2605
- const change = job.changes[offset];
2606
- await this.apply(owner, ownerRoot, change);
2607
- this.index.applied(owner, change, offset + 1);
2608
- count++;
2609
- if (change.kind === "upsert") bytes += change.size;
3390
+ return this.pending;
3391
+ }
3392
+ async once() {
3393
+ const prior = this.runtime.status(this.root);
3394
+ const report = await this.tick();
3395
+ if (this.control.enabled) await this.control.tick();
3396
+ const errors = [
3397
+ ...report.issues,
3398
+ ...report.sessions.issues,
3399
+ ...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
3400
+ (e) => Boolean(e)
3401
+ ) : []
3402
+ ].map(redact);
3403
+ if (errors[0] && !this.stopped) {
3404
+ const status = this.runtime.status(this.root);
3405
+ this.runtime.save(this.root, {
3406
+ ...status,
3407
+ state: "waiting",
3408
+ lastSuccessAt: prior?.lastSuccessAt ?? null,
3409
+ issue: errors[0]
3410
+ });
3411
+ if (errors[0] !== report.errors[0]) this.log.write("sync.waiting", this.root, errors[0]);
2610
3412
  }
2611
- if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
2612
- return count;
3413
+ return { ...report, errors, control: this.control.enabled ? this.control.status : null };
2613
3414
  }
2614
- async apply(owner, root, change) {
2615
- const prior = this.index.received(owner, change.path);
2616
- const current = await metadata(root, change.path);
2617
- let local;
2618
- if (current && !current.directory) local = await readStable(root, change.path);
2619
- if (change.kind === "delete") {
2620
- if (!current) return;
2621
- if (current.directory && !prior) return;
2622
- if (!local || !prior || local.sha256 !== prior.sha256)
2623
- throw new Error(`Local mirror differs; preserving ${change.path}.`);
2624
- await removeFile(root, change.path, local.signature);
3415
+ async cycle() {
3416
+ const prior = this.runtime.status(this.root);
3417
+ this.runtime.save(this.root, {
3418
+ state: "syncing",
3419
+ checkedAt: prior?.checkedAt ?? null,
3420
+ lastSuccessAt: prior?.lastSuccessAt ?? null,
3421
+ pendingFiles: prior?.pendingFiles ?? 0,
3422
+ pendingDownloads: prior?.pendingDownloads ?? 0,
3423
+ issue: null
3424
+ });
3425
+ let report = {
3426
+ published: false,
3427
+ appliedFiles: 0,
3428
+ waitingFiles: 0,
3429
+ pendingPublication: false,
3430
+ issues: []
3431
+ };
3432
+ let sessions = { published: 0, checked: 0, issues: [] };
3433
+ try {
3434
+ await verifyWorkspace(this.state, this.root);
3435
+ if (!this.controlStarted) {
3436
+ await this.control.tick();
3437
+ this.control.start();
3438
+ this.controlStarted = true;
3439
+ }
3440
+ if (this.files) report = await this.files.cycle();
3441
+ sessions = await this.sessions.cycle();
3442
+ } catch (e) {
3443
+ report.issues.push(diagnostic(e));
3444
+ }
3445
+ const errors = [
3446
+ ...report.issues,
3447
+ ...sessions.issues,
3448
+ ...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
3449
+ (e) => Boolean(e)
3450
+ ) : []
3451
+ ].map(redact);
3452
+ const pendingDownloads = Number(
3453
+ this.state.db.prepare(
3454
+ "SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM receive_jobs WHERE root=?"
3455
+ ).get(this.root).n
3456
+ );
3457
+ const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0);
3458
+ const now = Date.now(), checkedAt = new Date(now).toISOString();
3459
+ this.control.updateSync({
3460
+ checkedAt,
3461
+ published: report.published,
3462
+ appliedFiles: report.appliedFiles,
3463
+ pendingFiles,
3464
+ sessionSources: Number(
3465
+ this.state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(this.root).n
3466
+ ),
3467
+ errors: errors.slice(0, 20).map((e) => e.slice(0, 1e3))
3468
+ });
3469
+ const issue = errors[0] ?? "";
3470
+ const issueKey = issue.replace(/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}/gi, "[request]");
3471
+ if (issue) {
3472
+ this.issueCount++;
3473
+ if (issueKey !== this.lastIssue || now - this.lastLogAt >= 9e5) {
3474
+ this.log.write("sync.waiting", this.root, issue);
3475
+ this.lastLogAt = now;
3476
+ }
2625
3477
  } else {
2626
- if (local?.sha256 === change.sha256) return;
2627
- if (local && (!prior || local.sha256 !== prior.sha256))
2628
- throw new Error(`Local mirror differs; preserving ${change.path}.`);
2629
- if (!current && prior && !this.options.restoreMissing)
2630
- throw new Error(
2631
- `Local mirror was removed; use --restore-missing to restore ${change.path}.`
3478
+ if (this.lastIssue)
3479
+ this.log.write("sync.recovered", this.root, `${this.issueCount} failed checks`);
3480
+ this.issueCount = 0;
3481
+ if (report.published || report.appliedFiles || sessions.published)
3482
+ this.log.write(
3483
+ "sync.changed",
3484
+ this.root,
3485
+ `files received=${report.appliedFiles}, files published=${Number(report.published)}, sessions published=${sessions.published}`
2632
3486
  );
2633
- let content = await this.blobs.get(change.sha256);
2634
- if (!content) {
2635
- content = await this.client.downloadBlob(change.sha256);
2636
- await this.blobs.put(content);
2637
- }
2638
- if (content.byteLength !== change.size)
2639
- throw new Error(`Content size mismatch: ${change.path}`);
2640
- await replaceFile(root, change.path, content, local?.signature ?? null);
2641
3487
  }
3488
+ this.lastIssue = issueKey;
3489
+ this.nextAt = now + (issue ? Math.min(9e5, Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)) : pendingDownloads ? 250 : this.intervalMs);
3490
+ this.runtime.save(this.root, {
3491
+ state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
3492
+ checkedAt,
3493
+ lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
3494
+ pendingFiles,
3495
+ pendingDownloads,
3496
+ issue: issue || null
3497
+ });
3498
+ return {
3499
+ ...report,
3500
+ sessions,
3501
+ pendingDownloads,
3502
+ control: this.control.enabled ? this.control.status : null,
3503
+ errors
3504
+ };
3505
+ }
3506
+ close() {
3507
+ if (this.closing) return this.closing;
3508
+ this.stopped = true;
3509
+ this.abort.abort();
3510
+ this.files?.close();
3511
+ return this.closing = (async () => {
3512
+ await this.control.close();
3513
+ await this.pending?.catch(() => {
3514
+ });
3515
+ await this.sessions.close();
3516
+ const prior = this.runtime.status(this.root);
3517
+ if (prior) this.runtime.save(this.root, { ...prior, state: "stopped" });
3518
+ this.state.close();
3519
+ this.lock.close();
3520
+ })();
2642
3521
  }
2643
3522
  };
2644
- function message(error) {
2645
- return error instanceof Error ? error.message : String(error);
2646
- }
2647
3523
 
2648
3524
  // apps/client/sync-command.ts
2649
- var help2 = `Usage: teammy sync [options]
3525
+ var help2 = `Usage: teammy sync on | off [--root DIR]
2650
3526
 
2651
- --root DIR Shared root (default: ./workspaces)
2652
- --state-dir DIR Local state (default: ~/.teammy/client)
2653
- --once Run one check and exit; pending changes remain saved
2654
- --interval-ms N Check interval (default: 60000, minimum: 1000)
2655
- --settle-ms N Per-file quiet period (default: 60000)
2656
- --adopt-baseline Accept existing server history only if every published file matches locally
2657
- --restore-missing Restore missing mirror files; existing local edits are still preserved
2658
- --retry Retry file/session publications paused by a previous failure
2659
- --sessions-only Collect sessions without applying shared files
2660
- -h, --help Show this help
3527
+ on Start background sync and resume after login
3528
+ off Stop this project's sync, including after login
3529
+ --once Run one check without changing the enabled state
3530
+ --foreground Run in this terminal for diagnostics (turn background sync off first)
3531
+ --json Machine-readable output
2661
3532
 
2662
- Runs in the foreground until SIGINT/SIGTERM. Each run restarts the quiet period
2663
- for pending files; --once does not wait for edits to settle. --settle-ms 0 can be
2664
- used for an explicit immediate check. Only this member's directory is uploaded.
3533
+ Advanced: --state-dir DIR, --interval-ms N, --settle-ms N,
3534
+ --restore-missing, --adopt-baseline, --retry, --sessions-only
3535
+ Use teammy status or teammy logs for diagnostics.
2665
3536
  `;
2666
3537
  async function runSyncCommand(args, version) {
2667
- const { values } = parseArgs2({
2668
- args,
3538
+ const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
3539
+ const { values } = parseArgs3({
3540
+ args: action ? args.slice(1) : args,
2669
3541
  options: {
2670
- root: { type: "string", default: resolve5("workspaces") },
2671
- "state-dir": { type: "string", default: join10(homedir2(), ".teammy", "client") },
3542
+ ...locationOptions,
2672
3543
  once: { type: "boolean" },
3544
+ foreground: { type: "boolean" },
2673
3545
  "interval-ms": { type: "string" },
2674
3546
  "settle-ms": { type: "string" },
2675
3547
  "adopt-baseline": { type: "boolean" },
2676
3548
  "restore-missing": { type: "boolean" },
2677
3549
  retry: { type: "boolean" },
2678
- "sessions-only": { type: "boolean" },
2679
- help: { type: "boolean", short: "h" }
3550
+ "sessions-only": { type: "boolean" }
2680
3551
  }
2681
3552
  });
2682
- if (values.help) {
3553
+ if (values.help || !action && !values.once && !values.foreground) {
2683
3554
  process.stdout.write(help2);
2684
3555
  return 0;
2685
3556
  }
2686
- let intervalMs = Number(values["interval-ms"] ?? 6e4);
2687
- const settleMs = Number(values["settle-ms"] ?? 6e4);
2688
- if (!Number.isSafeInteger(intervalMs) || intervalMs < 1e3 || intervalMs > 864e5 || !Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > 864e5)
2689
- throw new Error("Invalid sync timing options.");
2690
- const root = await realpath5(resolve5(values.root));
2691
- const stateDirectory = await stateLocation(root, values["state-dir"]);
2692
- const lock = acquireRootLock(root, stateDirectory);
2693
- let state;
2694
- let synchronizer;
2695
- let sessions;
2696
- let control;
2697
- const stopped = new AbortController();
3557
+ if (action && (values.once || values.foreground) || values.once && values.foreground)
3558
+ throw new Error("Choose sync on, sync off, --once or --foreground.");
3559
+ const options = {};
3560
+ for (const [key, target, min] of [
3561
+ ["interval-ms", "intervalMs", 1e3],
3562
+ ["settle-ms", "settleMs", 0]
3563
+ ]) {
3564
+ if (values[key] === void 0) continue;
3565
+ const n = Number(values[key]);
3566
+ if (!Number.isSafeInteger(n) || n < min || n > 864e5)
3567
+ throw new Error("Invalid sync timing options.");
3568
+ options[target] = n;
3569
+ }
3570
+ if (values["adopt-baseline"]) options.adoptBaseline = true;
3571
+ if (values["restore-missing"]) options.restoreMissing = true;
3572
+ if (values.retry) options.retry = true;
3573
+ if (values["sessions-only"]) options.sessionsOnly = true;
3574
+ if (action && Object.keys(options).length)
3575
+ throw new Error(
3576
+ "Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
3577
+ );
3578
+ const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
3579
+ const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
3580
+ try {
3581
+ if (!state.binding(root)) throw new Error("Connect this workspace first.");
3582
+ if (action) {
3583
+ if (action === "on") await verifyWorkspace(state, root);
3584
+ runtime.enable(root, action === "on");
3585
+ if (action === "on") await wakeDaemon(directory, version);
3586
+ else {
3587
+ if (await runningDaemon(directory))
3588
+ await daemonRequest(directory, { method: "wake" }, 45e3);
3589
+ const status = runtime.status(root);
3590
+ if (status) runtime.save(root, { ...status, state: "stopped" });
3591
+ log.write("sync.off_requested", root);
3592
+ }
3593
+ if (values.json) json({ enabled: action === "on", root });
3594
+ else result(action === "on" ? "\u540E\u53F0\u540C\u6B65\u5DF2\u5F00\u542F" : "\u540C\u6B65\u5DF2\u505C\u6B62", root);
3595
+ return 0;
3596
+ }
3597
+ const daemon = await runningDaemon(directory);
3598
+ if (daemon && values.once && !Object.keys(options).length) {
3599
+ const report = await daemonRequest(directory, { method: "once", root }, 6e5);
3600
+ if (values.json) json(report);
3601
+ else result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
3602
+ return report.errors.length ? 1 : 0;
3603
+ }
3604
+ if (runtime.enabled(root))
3605
+ throw new Error(
3606
+ 'Run "teammy sync off" before using foreground synchronization or recovery overrides.'
3607
+ );
3608
+ const job = new SyncJob(root, directory, version, log, options);
3609
+ const abort = new AbortController();
3610
+ const stop = () => {
3611
+ abort.abort();
3612
+ void job.close();
3613
+ };
3614
+ process.once("SIGINT", stop);
3615
+ process.once("SIGTERM", stop);
3616
+ let prior = "";
3617
+ try {
3618
+ if (values.foreground && !values.json) result("\u540C\u6B65\u8FD0\u884C\u4E2D", "\u6309 Ctrl+C \u505C\u6B62");
3619
+ do {
3620
+ const report = await (values.once ? job.once() : job.tick());
3621
+ if (values.json) json(report);
3622
+ else if (values.once)
3623
+ result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
3624
+ else if ((report.errors[0] ?? "") !== prior) {
3625
+ result(report.errors.length ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : "\u540C\u6B65\u5DF2\u6062\u590D", report.errors[0]);
3626
+ prior = report.errors[0] ?? "";
3627
+ }
3628
+ if (values.once) return report.errors.length ? 1 : 0;
3629
+ await setTimeout4(Math.max(100, job.nextAt - Date.now()), void 0, {
3630
+ signal: abort.signal
3631
+ }).catch((e) => {
3632
+ if (!abort.signal.aborted) throw e;
3633
+ });
3634
+ } while (!abort.signal.aborted);
3635
+ } finally {
3636
+ process.off("SIGINT", stop);
3637
+ process.off("SIGTERM", stop);
3638
+ await job.close();
3639
+ }
3640
+ return 0;
3641
+ } catch (e) {
3642
+ log.write("sync.command_failed", root, diagnostic(e));
3643
+ throw e;
3644
+ } finally {
3645
+ state.close();
3646
+ }
3647
+ }
3648
+
3649
+ // apps/client/runtime/daemon.ts
3650
+ import { createServer } from "node:net";
3651
+ import { chmod as chmod2, unlink as unlink4 } from "node:fs/promises";
3652
+ async function runDaemon(directory, version) {
3653
+ const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
3654
+ const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
3655
+ let closing = false, serial = Promise.resolve();
3656
+ const reconcile = () => {
3657
+ serial = serial.catch(() => {
3658
+ }).then(async () => {
3659
+ if (closing) return;
3660
+ const enabled = new Set(runtime.roots());
3661
+ for (const [root, job] of jobs)
3662
+ if (!enabled.has(root)) {
3663
+ await job.close();
3664
+ jobs.delete(root);
3665
+ log.write("sync.off", root);
3666
+ }
3667
+ for (const root of enabled) {
3668
+ if (jobs.has(root) || oneShots.has(root) || (retry.get(root) ?? 0) > Date.now()) continue;
3669
+ try {
3670
+ jobs.set(root, new SyncJob(root, directory, version, log));
3671
+ retry.delete(root);
3672
+ log.write("sync.on", root);
3673
+ } catch (e) {
3674
+ retry.set(root, Date.now() + 6e4);
3675
+ log.write("sync.start_failed", root, diagnostic(e));
3676
+ runtime.save(root, {
3677
+ state: "waiting",
3678
+ checkedAt: null,
3679
+ lastSuccessAt: runtime.status(root)?.lastSuccessAt ?? null,
3680
+ pendingFiles: 0,
3681
+ pendingDownloads: 0,
3682
+ issue: diagnostic(e)
3683
+ });
3684
+ }
3685
+ }
3686
+ for (const job of jobs.values())
3687
+ if (job.nextAt <= Date.now())
3688
+ void job.tick().catch((e) => {
3689
+ job.nextAt = Date.now() + 6e4;
3690
+ log.write("sync.failed", job.root, diagnostic(e));
3691
+ });
3692
+ });
3693
+ return serial;
3694
+ };
3695
+ const sockets = /* @__PURE__ */ new Set(), temporaryJobs = /* @__PURE__ */ new Set();
3696
+ const oneShots = /* @__PURE__ */ new Map();
3697
+ const server = createServer((socket) => {
3698
+ sockets.add(socket);
3699
+ socket.once("close", () => sockets.delete(socket));
3700
+ let body = "", used = false;
3701
+ socket.setEncoding("utf8");
3702
+ socket.setTimeout(6e5, () => socket.destroy());
3703
+ socket.on("error", () => {
3704
+ });
3705
+ socket.on("data", (chunk) => {
3706
+ if (used) return;
3707
+ body += chunk.toString();
3708
+ if (body.length > 8192) {
3709
+ socket.destroy();
3710
+ return;
3711
+ }
3712
+ if (!body.includes("\n")) return;
3713
+ used = true;
3714
+ void (async () => {
3715
+ const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
3716
+ if (closing) throw new Error("Background service is stopping.");
3717
+ if (input.method === "ping") return { pid: process.pid, version };
3718
+ if (input.method === "wake") {
3719
+ retry.clear();
3720
+ await reconcile();
3721
+ return { ok: true };
3722
+ }
3723
+ const root = input.root;
3724
+ if (!root || !state.binding(root)) throw new Error("Workspace is not connected.");
3725
+ if (input.method === "status")
3726
+ return {
3727
+ enabled: runtime.enabled(root),
3728
+ running: jobs.has(root),
3729
+ status: runtime.status(root)
3730
+ };
3731
+ const active = jobs.get(root);
3732
+ if (active) return active.once();
3733
+ if (runtime.enabled(root)) throw new Error("Workspace could not start. Run teammy status.");
3734
+ let work = oneShots.get(root);
3735
+ if (!work) {
3736
+ const job = new SyncJob(root, directory, version, log);
3737
+ temporaryJobs.add(job);
3738
+ work = job.once().finally(async () => {
3739
+ await job.close();
3740
+ temporaryJobs.delete(job);
3741
+ oneShots.delete(root);
3742
+ });
3743
+ oneShots.set(root, work);
3744
+ }
3745
+ return work;
3746
+ })().then(
3747
+ (result2) => socket.end(JSON.stringify({ ok: true, result: result2 }) + "\n"),
3748
+ (e) => socket.end(JSON.stringify({ ok: false, error: diagnostic(e) }) + "\n")
3749
+ );
3750
+ });
3751
+ });
3752
+ let timer;
3753
+ let resolveStop = () => {
3754
+ };
3755
+ const stopped = new Promise((resolve9) => {
3756
+ resolveStop = resolve9;
3757
+ });
2698
3758
  const stop = () => {
2699
- stopped.abort();
2700
- synchronizer?.close();
3759
+ closing = true;
3760
+ resolveStop();
2701
3761
  };
2702
3762
  try {
2703
- state = new ClientState(stateDirectory);
2704
- if (values.retry) state.retryPublication(root);
2705
- sessions = new SessionSynchronizer(state, root, stateDirectory, stopped.signal);
2706
- if (values.retry) sessions.index.retry();
2707
- if (!values["sessions-only"])
2708
- synchronizer = new FileSynchronizer(state, root, stateDirectory, {
2709
- settleMs,
2710
- adoptBaseline: Boolean(values["adopt-baseline"]),
2711
- restoreMissing: Boolean(values["restore-missing"])
3763
+ await prepareSocketDirectory(directory);
3764
+ await unlink4(socketPath(directory)).catch((e) => {
3765
+ if (e.code !== "ENOENT") throw e;
3766
+ });
3767
+ await new Promise((resolve9, reject) => {
3768
+ server.once("error", reject);
3769
+ server.listen(socketPath(directory), () => {
3770
+ server.off("error", reject);
3771
+ resolve9();
2712
3772
  });
2713
- const overrides = {};
2714
- if (values["interval-ms"] !== void 0) overrides.checkIntervalSeconds = intervalMs / 1e3;
2715
- if (values["settle-ms"] !== void 0) overrides.fileSettleSeconds = settleMs / 1e3;
2716
- control = new ControlPlane(state, root, version, overrides, (policy) => {
2717
- intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
2718
- synchronizer?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
2719
3773
  });
2720
- await control.tick();
2721
- await synchronizer?.checkCapabilities();
2722
- if (!values.once) control.start();
3774
+ await chmod2(socketPath(directory), 384);
2723
3775
  process.once("SIGINT", stop);
2724
3776
  process.once("SIGTERM", stop);
2725
- do {
2726
- const report = synchronizer ? await synchronizer.cycle() : { issues: [], published: false, appliedFiles: 0, waitingFiles: 0 };
2727
- let sessionReport;
2728
- try {
2729
- sessionReport = await sessions.cycle();
2730
- } catch (error) {
2731
- sessionReport = {
2732
- published: 0,
2733
- checked: 0,
2734
- issues: [error instanceof Error ? error.message : "Session synchronization failed."]
2735
- };
2736
- }
2737
- control.updateSync({
2738
- checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2739
- published: report.published,
2740
- appliedFiles: report.appliedFiles,
2741
- pendingFiles: report.waitingFiles + (state.pending(root)?.batch.changes.length ?? 0),
2742
- sessionSources: Number(
2743
- state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(root).n
2744
- ),
2745
- errors: [...report.issues, ...sessionReport.issues].slice(0, 20).map((message2) => message2.slice(0, 1e3))
2746
- });
2747
- if (values.once && control.enabled) await control.tick();
2748
- const combined = {
2749
- ...report,
2750
- sessions: sessionReport,
2751
- control: control.enabled ? control.status : null
2752
- };
2753
- process.stdout.write(`${JSON.stringify(combined)}
2754
- `);
2755
- if (values.once)
2756
- return report.issues.length || sessionReport.issues.length || control.enabled && (control.status.configError || control.status.reportError) ? 1 : 0;
2757
- const finishedAt = Date.now();
2758
- while (!stopped.signal.aborted && Date.now() - finishedAt < intervalMs)
2759
- await setTimeout3(Math.min(1e3, intervalMs - (Date.now() - finishedAt)), void 0, {
2760
- signal: stopped.signal
2761
- }).catch((error) => {
2762
- if (!stopped.signal.aborted) throw error;
2763
- });
2764
- } while (!stopped.signal.aborted);
2765
- return 0;
3777
+ log.write("daemon.started", void 0, `version=${version}`);
3778
+ await reconcile();
3779
+ timer = setInterval(
3780
+ () => void reconcile().catch((e) => log.write("daemon.error", void 0, diagnostic(e))),
3781
+ 1e3
3782
+ );
3783
+ await stopped;
2766
3784
  } finally {
3785
+ closing = true;
3786
+ clearInterval(timer);
2767
3787
  process.off("SIGINT", stop);
2768
3788
  process.off("SIGTERM", stop);
2769
- synchronizer?.close();
2770
- await control?.close();
2771
- await sessions?.close();
2772
- state?.close();
3789
+ await serial.catch(() => {
3790
+ });
3791
+ await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
3792
+ await Promise.allSettled(oneShots.values());
3793
+ for (const socket of sockets) socket.destroy();
3794
+ await new Promise((resolve9) => server.close(() => resolve9()));
3795
+ await unlink4(socketPath(directory)).catch(() => {
3796
+ });
3797
+ log.write("daemon.stopped");
3798
+ state.close();
2773
3799
  lock.close();
2774
3800
  }
2775
3801
  }
@@ -2783,13 +3809,15 @@ Usage:
2783
3809
  teammy --version Show the installed client version
2784
3810
  teammy server-info --url URL Check server connection and protocol capabilities
2785
3811
  teammy server-info --help Show connection options
2786
- teammy connect --url URL [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
3812
+ teammy connect --url URL --project ID [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
2787
3813
  teammy status [--root DIR] [--state-dir DIR]
2788
- teammy sync [--root DIR] [--once]
3814
+ teammy sync on | off [--root DIR]
3815
+ teammy logs [--root DIR] Show recent diagnostic events
2789
3816
  teammy session --help Collect, read and search Agent sessions
2790
3817
 
2791
3818
  Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
2792
- sync runs foreground synchronization; manage project settings in the server dashboard.
3819
+ connect starts background sync; enabled projects resume after login.
3820
+ Use --json for scripts; manage sync timing in the server dashboard.
2793
3821
  The hub server is installed separately as @teammy/server (command: teammy-server).
2794
3822
  `;
2795
3823
  var serverInfoHelp = `Usage: teammy server-info --url URL
@@ -2816,6 +3844,21 @@ async function runCli(args, version) {
2816
3844
  process.stderr.write('Install @teammy/server and run "teammy-server" to start the hub.\n');
2817
3845
  return 1;
2818
3846
  }
3847
+ if (command === "_daemon") {
3848
+ const { values } = parseArgs4({
3849
+ args: args.slice(1),
3850
+ options: { "state-dir": { type: "string" } }
3851
+ });
3852
+ if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
3853
+ const directory = resolve8(values["state-dir"]);
3854
+ try {
3855
+ await runDaemon(directory, version);
3856
+ return 0;
3857
+ } catch (e) {
3858
+ new ClientLog(directory).write("daemon.failed", void 0, diagnostic(e));
3859
+ return 1;
3860
+ }
3861
+ }
2819
3862
  if (command === "session") {
2820
3863
  try {
2821
3864
  return await runSessionCommand(args.slice(1));
@@ -2832,64 +3875,17 @@ async function runCli(args, version) {
2832
3875
  try {
2833
3876
  return await runSyncCommand(args.slice(1), version);
2834
3877
  } catch (error) {
2835
- process.stderr.write(
2836
- `${error instanceof Error ? error.message : "Synchronization failed."}
2837
- `
2838
- );
3878
+ process.stderr.write(`${diagnostic(error)}
3879
+ `);
2839
3880
  return 1;
2840
3881
  }
2841
3882
  }
2842
- if (command === "connect" || command === "status") {
3883
+ if (command === "connect" || command === "status" || command === "logs") {
2843
3884
  try {
2844
- const { values } = parseArgs3({
2845
- args: args.slice(1),
2846
- options: {
2847
- url: { type: "string" },
2848
- "token-file": { type: "string" },
2849
- member: { type: "string" },
2850
- reconnect: { type: "boolean" },
2851
- root: { type: "string", default: resolve6("workspaces") },
2852
- "state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") }
2853
- }
2854
- });
2855
- const rootPath = resolve6(values.root);
2856
- if (command === "connect") await mkdir7(rootPath, { recursive: true });
2857
- const root = await realpath6(rootPath);
2858
- const state = new ClientState(await stateLocation(root, values["state-dir"]));
2859
- try {
2860
- if (command === "status") {
2861
- const binding = state.binding(root);
2862
- if (!binding) throw new Error("This root is not connected. Run teammy connect first.");
2863
- const { token: _token, ...publicBinding } = binding;
2864
- process.stdout.write(
2865
- `${JSON.stringify({ ...publicBinding, control: readControlStatus(state, root), sessions: new SessionState(state, root).status(), publication: state.publication(root), pending: state.pending(root) ? { operationId: state.pending(root).batch.operationId, retryAt: state.pending(root).retryAt, lastError: state.pending(root).lastError } : null, sync: new SyncState(state, root).status() }, null, 2)}
2866
- `
2867
- );
2868
- } else {
2869
- if (!values.url) throw new Error("--url is required.");
2870
- const connected = await connectProject(
2871
- state,
2872
- root,
2873
- await stateLocation(root, values["state-dir"]),
2874
- {
2875
- url: values.url,
2876
- member: values.member,
2877
- tokenFile: values["token-file"],
2878
- reconnect: values.reconnect
2879
- }
2880
- );
2881
- process.stdout.write(`${JSON.stringify(connected, null, 2)}
3885
+ return await runClientCommand(command, args.slice(1), version);
3886
+ } catch (e) {
3887
+ process.stderr.write(`${diagnostic(e)}
2882
3888
  `);
2883
- }
2884
- } finally {
2885
- state.close();
2886
- }
2887
- return 0;
2888
- } catch (error) {
2889
- process.stderr.write(
2890
- `${error instanceof Error ? error.message : "Unable to open connection."}
2891
- `
2892
- );
2893
3889
  return 1;
2894
3890
  }
2895
3891
  }
@@ -2898,7 +3894,7 @@ async function runCli(args, version) {
2898
3894
  return 1;
2899
3895
  }
2900
3896
  try {
2901
- const { values } = parseArgs3({
3897
+ const { values } = parseArgs4({
2902
3898
  args: args.slice(1),
2903
3899
  options: {
2904
3900
  help: { type: "boolean", short: "h" },