@teammy/cli 0.0.5 → 0.0.7

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,193 +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 SessionStatsSchema = z5.object({
234
- totalSessions: z5.number().int().nonnegative(),
235
- totalMessages: z5.number().int().nonnegative(),
236
- memberCount: z5.number().int().nonnegative(),
237
- updatedAt: z5.string().nullable()
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()
238
239
  });
239
- var SessionSearchSchema = z5.object({
240
- matches: z5.array(
241
- z5.object({
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({
242
247
  owner: NameSchema,
243
248
  sessionKey: HashSchema,
244
249
  revision: RevisionSchema,
245
- messageId: z5.string(),
246
- role: z5.enum(["user", "assistant"]),
247
- snippet: z5.string()
250
+ messageId: z4.string(),
251
+ role: z4.enum(["user", "assistant"]),
252
+ snippet: z4.string(),
253
+ title: z4.string()
248
254
  })
249
255
  ),
250
256
  nextCursor: RevisionSchema.nullable()
251
257
  });
252
258
 
253
259
  // packages/transport/hub-client.ts
254
- import { z as z7 } from "zod";
260
+ import { z as z6 } from "zod";
255
261
  import { createHash } from "node:crypto";
256
262
 
257
263
  // packages/protocol/index.ts
258
- import { z as z6 } from "zod";
264
+ import { z as z5 } from "zod";
259
265
  var PROTOCOL_VERSION = 1;
260
- var HealthSchema = z6.object({ status: z6.literal("ok") });
261
- var ServerInfoSchema = z6.object({
262
- name: z6.literal("teammy"),
263
- version: z6.string().min(1),
264
- protocolVersion: z6.literal(PROTOCOL_VERSION),
265
- 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())
266
272
  });
267
- var ErrorResponseSchema = z6.object({
268
- error: z6.object({
269
- code: z6.string(),
270
- message: z6.string(),
271
- requestId: z6.string()
273
+ var ErrorResponseSchema = z5.object({
274
+ error: z5.object({
275
+ code: z5.string(),
276
+ message: z5.string(),
277
+ requestId: z5.string()
272
278
  })
273
279
  });
274
280
 
@@ -312,8 +318,11 @@ var HubClient = class {
312
318
  get url() {
313
319
  return this.baseUrl.toString();
314
320
  }
315
- bind(deviceId) {
316
- 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
+ });
317
326
  }
318
327
  members() {
319
328
  return this.request("GET", "api/v1/members", MembersSchema);
@@ -331,18 +340,18 @@ var HubClient = class {
331
340
  "GET",
332
341
  `api/v1/blobs/${HashSchema.parse(hash2)}`
333
342
  );
334
- if (!response.ok) return this.decode(response, signal, z7.never());
343
+ if (!response.ok) return this.decode(response, signal, z6.never());
335
344
  const reader = response.body?.getReader();
336
345
  if (!reader) throw new Error("Server returned no content body.");
337
346
  const parts = [];
338
347
  let size = 0;
339
348
  try {
340
349
  for (; ; ) {
341
- const result = await reader.read();
342
- if (result.done) break;
343
- size += result.value.byteLength;
350
+ const result2 = await reader.read();
351
+ if (result2.done) break;
352
+ size += result2.value.byteLength;
344
353
  if (size > MAX_BLOB_BYTES) throw new Error("Downloaded content exceeds the size limit.");
345
- parts.push(result.value);
354
+ parts.push(result2.value);
346
355
  }
347
356
  } finally {
348
357
  await reader.cancel();
@@ -376,7 +385,7 @@ var HubClient = class {
376
385
  return this.request(
377
386
  "PUT",
378
387
  "api/v1/devices/self/status",
379
- z7.object({ ok: z7.literal(true) }),
388
+ z6.object({ ok: z6.literal(true) }),
380
389
  status
381
390
  );
382
391
  }
@@ -420,6 +429,8 @@ var HubClient = class {
420
429
  return this.decode(response, signal, schema);
421
430
  }
422
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)}`;
423
434
  const timeout = AbortSignal.timeout(this.timeoutMs);
424
435
  const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
425
436
  const binary = requestBody instanceof Uint8Array;
@@ -470,64 +481,107 @@ var HubClient = class {
470
481
  }
471
482
  };
472
483
 
473
- // apps/client/project-state.ts
474
- import { randomBytes, randomUUID } from "node:crypto";
475
- var projectClientMigration = String.raw`
476
- CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
477
- 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);
478
497
  `;
479
- var ClientProjectState = class {
480
- constructor(state, root) {
498
+ var RuntimeState = class {
499
+ constructor(state) {
481
500
  this.state = state;
482
- this.root = root;
483
501
  }
484
502
  state;
485
- root;
486
- enrollment(url, memberId, reconnect = false) {
487
- const deviceId = this.state.deviceFor(this.root);
488
- const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
489
- if (row && !reconnect) {
490
- const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
491
- if (row.server_url !== url || request2.memberId !== memberId)
492
- throw new Error("Another enrollment is pending for this root; use the same member/server.");
493
- return request2;
494
- }
495
- const request = {
496
- operationId: randomUUID(),
497
- memberId,
498
- deviceId,
499
- credential: `tmy_${randomBytes(32).toString("hex")}`
500
- };
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.");
501
510
  this.state.db.prepare(
502
- "INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
503
- ).run(this.root, url, JSON.stringify(request));
504
- return request;
511
+ "INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
512
+ ).run(root, Number(enabled));
505
513
  }
506
- completeEnrollment() {
507
- 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));
508
516
  }
509
- cached() {
510
- const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
511
- return row ? {
512
- config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
513
- hash: String(row.descriptor_hash)
514
- } : 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;
515
520
  }
516
- save(config, hash2) {
521
+ save(root, status) {
517
522
  this.state.db.prepare(
518
- "INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
519
- ).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));
520
525
  }
521
526
  };
522
527
 
523
- // apps/client/project-file.ts
524
- 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";
525
579
 
526
580
  // apps/client/files/local-fs.ts
527
- import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
581
+ import { createHash as createHash2, randomUUID } from "node:crypto";
528
582
  import { constants } from "node:fs";
529
583
  import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
530
- import { dirname, join, relative, sep } from "node:path";
584
+ import { dirname as dirname2, join, relative, sep } from "node:path";
531
585
  import { setImmediate } from "node:timers/promises";
532
586
  function missing(error) {
533
587
  return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
@@ -657,7 +711,7 @@ async function syncDirectory(directory) {
657
711
  }
658
712
  async function replaceFile(root, path, bytes, expected) {
659
713
  const target = await safePath(root, path, true);
660
- const temporary = join(dirname(target), `.teammy-${randomUUID2()}.tmp`);
714
+ const temporary = join(dirname2(target), `.teammy-${randomUUID()}.tmp`);
661
715
  const file = await open(temporary, "wx", 384);
662
716
  try {
663
717
  try {
@@ -674,11 +728,11 @@ async function replaceFile(root, path, bytes, expected) {
674
728
  throw new Error(`Local file changed before replacement: ${path}`);
675
729
  await safePath(root, path);
676
730
  await rename(temporary, target);
677
- let directory = dirname(target);
731
+ let directory = dirname2(target);
678
732
  for (; ; ) {
679
733
  await syncDirectory(directory);
680
- if (directory === dirname(root)) break;
681
- directory = dirname(directory);
734
+ if (directory === dirname2(root)) break;
735
+ directory = dirname2(directory);
682
736
  }
683
737
  } finally {
684
738
  await unlink(temporary).catch((error) => {
@@ -693,125 +747,420 @@ async function removeFile(root, path, expected) {
693
747
  throw new Error(`Local file changed before deletion: ${path}`);
694
748
  const target = await safePath(root, path);
695
749
  await unlink(target);
696
- await syncDirectory(dirname(target));
697
- let parent = dirname(target);
750
+ await syncDirectory(dirname2(target));
751
+ let parent = dirname2(target);
698
752
  while (parent !== root) {
699
753
  try {
700
754
  await rmdir(parent);
701
- await syncDirectory(dirname(parent));
755
+ await syncDirectory(dirname2(parent));
702
756
  } catch {
703
757
  break;
704
758
  }
705
- parent = dirname(parent);
759
+ parent = dirname2(parent);
706
760
  }
707
761
  }
708
762
 
709
- // apps/client/project-file.ts
710
- var name = ".teammy.json";
711
- async function applyProjectFile(local, input) {
712
- const config = ProjectConfigSchema.parse(input);
713
- const binding = local.state.binding(local.root);
714
- if (!binding || binding.workspaceId !== config.projectId)
715
- throw new Error("Project configuration does not match the local binding.");
716
- const cached = local.cached();
717
- if (cached && config.configRevision < cached.config.configRevision)
718
- throw new Error("Server project configuration moved backwards.");
719
- const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
720
- const hash2 = createHash3("sha256").update(bytes).digest("hex");
721
- const entry = await metadata(local.root, name);
722
- let signature2 = null;
723
- if (entry) {
724
- if (entry.directory)
725
- throw new Error("Project configuration path is a directory; preserving it.");
726
- const existing = await readStable(local.root, name);
727
- signature2 = existing.signature;
728
- 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)
729
807
  throw new Error(
730
- "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."
731
809
  );
732
- if (existing.sha256 === hash2) {
733
- local.save(config, hash2);
810
+ try {
811
+ await exec("git", ["-C", parent, "check-ignore", "--no-index", "-q", "--", name2 + "/"]);
734
812
  return;
813
+ } catch (e) {
814
+ if (e.code !== 1) throw new Error("Cannot check Git ignore rules.");
735
815
  }
736
816
  }
737
- await replaceFile(local.root, name, bytes, signature2);
738
- 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
+ };
739
831
  }
740
-
741
- // apps/client/control.ts
742
- var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
743
- var ControlPlane = class {
744
- constructor(state, root, version, overrides, apply) {
745
- this.state = state;
746
- this.root = root;
747
- this.overrides = overrides;
748
- this.apply = apply;
749
- const binding = state.binding(root);
750
- if (!binding) throw new Error("Connect this root first.");
751
- this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
752
- this.local = new ClientProjectState(state, root);
753
- const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
754
- const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
755
- const cached = this.local.cached();
756
- this.status = {
757
- clientVersion: version,
758
- receivedRevision: cached?.config.configRevision ?? null,
759
- appliedRevision: null,
760
- effectivePolicy: EffectivePolicySchema.parse({
761
- ...DEFAULT_POLICY,
762
- ...cached?.config.sync,
763
- ...overrides
764
- }),
765
- overrides: Object.keys(overrides),
766
- configError: null,
767
- reportError: null,
768
- lastSync: prior?.lastSync ?? null
769
- };
770
- apply(this.status.effectivePolicy);
771
- }
772
- state;
773
- root;
774
- overrides;
775
- apply;
776
- client;
777
- local;
778
- status;
779
- enabled = true;
780
- stop = new AbortController();
781
- running;
782
- persist() {
783
- this.state.db.prepare(
784
- "INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
785
- ).run(this.root, JSON.stringify(this.status));
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
+ );
786
856
  }
787
- updateSync(summary) {
788
- this.status.lastSync = summary;
789
- this.persist();
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;
790
869
  }
791
- async tick() {
792
- if (this.stop.signal.aborted) return;
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);
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) {
793
901
  try {
794
- const config = await this.client.projectConfig();
795
- this.status.receivedRevision = config.configRevision;
796
- await applyProjectFile(this.local, config);
797
- const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
798
- this.apply(effective);
799
- this.status.effectivePolicy = effective;
800
- this.status.appliedRevision = config.configRevision;
801
- this.status.configError = null;
802
- } catch (error) {
803
- if (!this.local.cached() && error instanceof HubRequestError && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND", "NOT_FOUND"].includes(error.code)) {
804
- this.enabled = false;
805
- return;
902
+ let size = 0;
903
+ try {
904
+ size = statSync(this.path).size;
905
+ } catch {
806
906
  }
807
- this.status.configError = (error instanceof Error ? error.message : "Configuration update failed.").slice(0, 2e3);
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
+ };
948
+
949
+ // apps/client/control.ts
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) {
1003
+ this.state = state;
1004
+ this.root = root;
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;
1095
+ const cached = this.local.cached();
1096
+ this.status = {
1097
+ clientVersion: version,
1098
+ receivedRevision: cached?.config.configRevision ?? null,
1099
+ appliedRevision: null,
1100
+ effectivePolicy: EffectivePolicySchema.parse({
1101
+ ...DEFAULT_POLICY,
1102
+ ...cached?.config.sync,
1103
+ ...overrides
1104
+ }),
1105
+ overrides: Object.keys(overrides),
1106
+ configError: null,
1107
+ reportError: null,
1108
+ lastSync: prior?.lastSync ?? null
1109
+ };
1110
+ apply(this.status.effectivePolicy);
1111
+ }
1112
+ state;
1113
+ root;
1114
+ overrides;
1115
+ apply;
1116
+ client;
1117
+ local;
1118
+ status;
1119
+ enabled = true;
1120
+ stop = new AbortController();
1121
+ running;
1122
+ pending;
1123
+ persist() {
1124
+ this.state.db.prepare(
1125
+ "INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
1126
+ ).run(this.root, JSON.stringify(this.status));
1127
+ }
1128
+ updateSync(summary) {
1129
+ this.status.lastSync = summary;
1130
+ this.persist();
1131
+ }
1132
+ tick() {
1133
+ this.pending ??= this.check().finally(() => {
1134
+ this.pending = void 0;
1135
+ });
1136
+ return this.pending;
1137
+ }
1138
+ async check() {
1139
+ if (this.stop.signal.aborted) return;
1140
+ try {
1141
+ await verifyWorkspace(this.state, this.root);
1142
+ const config = await this.client.projectConfig();
1143
+ this.status.receivedRevision = config.configRevision;
1144
+ await verifyWorkspace(this.state, this.root);
1145
+ await applyProjectFile(this.local, config);
1146
+ const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
1147
+ this.apply(effective);
1148
+ this.status.effectivePolicy = effective;
1149
+ this.status.appliedRevision = config.configRevision;
1150
+ this.status.configError = null;
1151
+ } catch (error) {
1152
+ if (!this.local.cached() && error instanceof HubRequestError && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND", "NOT_FOUND"].includes(error.code)) {
1153
+ this.enabled = false;
1154
+ return;
1155
+ }
1156
+ this.status.configError = diagnostic(error);
808
1157
  }
809
1158
  this.persist();
810
1159
  this.status.reportError = null;
811
1160
  try {
812
1161
  await this.client.reportDeviceStatus(this.status);
813
1162
  } catch (error) {
814
- this.status.reportError = String(error).slice(0, 2e3);
1163
+ this.status.reportError = diagnostic(error);
815
1164
  }
816
1165
  this.persist();
817
1166
  }
@@ -824,7 +1173,7 @@ var ControlPlane = class {
824
1173
  await this.tick();
825
1174
  } catch (error) {
826
1175
  if (!this.stop.signal.aborted) {
827
- this.status.configError = String(error).slice(0, 2e3);
1176
+ this.status.configError = diagnostic(error);
828
1177
  }
829
1178
  }
830
1179
  }
@@ -833,6 +1182,7 @@ var ControlPlane = class {
833
1182
  async close() {
834
1183
  this.stop.abort();
835
1184
  await this.running;
1185
+ await this.pending;
836
1186
  }
837
1187
  };
838
1188
  function readControlStatus(state, root) {
@@ -840,176 +1190,39 @@ function readControlStatus(state, root) {
840
1190
  return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
841
1191
  }
842
1192
 
843
- // apps/client/connection.ts
844
- import { readFile, mkdir as mkdir2 } from "node:fs/promises";
845
- import { createInterface } from "node:readline/promises";
846
-
847
- // apps/client/root-lock.ts
848
- import { createHash as createHash4 } from "node:crypto";
849
- import { join as join2 } from "node:path";
850
-
851
- // packages/state/sqlite.ts
852
- import { chmodSync, mkdirSync } from "node:fs";
853
- import { dirname as dirname2 } from "node:path";
854
- import { DatabaseSync } from "node:sqlite";
855
- function transaction(db, action) {
856
- db.exec("BEGIN IMMEDIATE");
857
- try {
858
- const result = action();
859
- db.exec("COMMIT");
860
- return result;
861
- } catch (error) {
862
- db.exec("ROLLBACK");
863
- throw error;
1193
+ // apps/client/sessions/state.ts
1194
+ var sessionClientMigration = String.raw`
1195
+ CREATE TABLE session_sources (
1196
+ root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
1197
+ session_key TEXT, revision INTEGER NOT NULL DEFAULT 0, hash TEXT, signature TEXT,
1198
+ checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0,
1199
+ pending_json TEXT, pending_signature TEXT, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
1200
+ PRIMARY KEY(root, path), UNIQUE(root, session_key)
1201
+ );
1202
+ CREATE INDEX session_source_scheduling ON session_sources(root, checked_at, path);
1203
+ CREATE TABLE session_discovery (
1204
+ root TEXT NOT NULL REFERENCES connections(root), directory TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
1205
+ PRIMARY KEY(root, directory)
1206
+ );
1207
+ CREATE TABLE session_discovery_cache (
1208
+ root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
1209
+ );
1210
+ `;
1211
+ var SessionState = class {
1212
+ constructor(state, root) {
1213
+ this.state = state;
1214
+ this.root = root;
864
1215
  }
865
- }
866
- function openDatabase(path, migrations) {
867
- mkdirSync(dirname2(path), { recursive: true, mode: 448 });
868
- const db = new DatabaseSync(path);
869
- try {
870
- chmodSync(path, 384);
871
- db.exec(
872
- "PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"
873
- );
874
- transaction(db, () => {
875
- const version = Number(db.prepare("PRAGMA user_version").get().user_version);
876
- if (version > migrations.length)
877
- throw new Error("Database was created by a newer Teammy version.");
878
- for (let index = version; index < migrations.length; index++) {
879
- db.exec(migrations[index]);
880
- db.exec(`PRAGMA user_version = ${index + 1}`);
881
- }
882
- });
883
- return db;
884
- } catch (error) {
885
- db.close();
886
- throw error;
887
- }
888
- }
889
-
890
- // apps/client/root-lock.ts
891
- function acquireRootLock(root, stateDirectory) {
892
- let lock;
893
- try {
894
- lock = openDatabase(
895
- join2(stateDirectory, "locks", `${createHash4("sha256").update(root).digest("hex")}.sqlite3`),
896
- []
897
- );
898
- lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
899
- return lock;
900
- } catch (error) {
901
- lock?.close();
902
- if (error instanceof Error && /locked|busy/.test(error.message))
903
- throw new Error("Another sync or connect process is using this root.");
904
- throw error;
905
- }
906
- }
907
-
908
- // apps/client/connection.ts
909
- async function connectProject(state, root, directory, options) {
910
- const lock = acquireRootLock(root, directory);
911
- try {
912
- const transport = new HubClient(options.url);
913
- const existing = state.binding(root);
914
- const project = new ClientProjectState(state, root);
915
- if (existing && existing.serverUrl !== transport.url)
916
- throw new Error("This root is connected to another server.");
917
- let member = options.member;
918
- if (existing && !options.reconnect && !options.tokenFile) {
919
- if (member && member !== existing.memberId)
920
- throw new Error("This root belongs to a different member.");
921
- const client2 = new HubClient(existing.serverUrl, 1e4, existing);
922
- await client2.members();
923
- } else if (options.tokenFile) {
924
- const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
925
- const deviceId = state.deviceFor(root);
926
- const client2 = new HubClient(options.url, 1e4, { token, deviceId });
927
- const binding2 = await client2.bind(deviceId);
928
- state.saveBinding(root, client2.url, token, binding2);
929
- } else {
930
- member ??= existing?.memberId;
931
- if (!member && process.stdin.isTTY) {
932
- const rl = createInterface({ input: process.stdin, output: process.stderr });
933
- try {
934
- member = (await rl.question("Member ID: ")).trim();
935
- } finally {
936
- rl.close();
937
- }
938
- }
939
- if (!member) throw new Error("--member is required when input is not interactive.");
940
- NameSchema.parse(member);
941
- if (existing && existing.memberId !== member)
942
- throw new Error("This root belongs to a different member.");
943
- const request = project.enrollment(transport.url, member, Boolean(options.reconnect));
944
- const binding2 = await transport.enroll(request);
945
- state.saveBinding(root, transport.url, request.credential, binding2);
946
- project.completeEnrollment();
947
- }
948
- const binding = state.binding(root);
949
- const client = new HubClient(binding.serverUrl, 1e4, binding);
950
- const info = await client.serverInfo();
951
- if (info.capabilities.includes("project-config")) {
952
- try {
953
- await applyProjectFile(project, await client.projectConfig());
954
- } catch (error) {
955
- if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
956
- throw error;
957
- }
958
- }
959
- const own = await metadata(root, binding.memberId);
960
- if (own && !own.directory) throw new Error("Member path is not a directory.");
961
- if (!own) await mkdir2(await safePath(root, binding.memberId));
962
- const { token: _, ...visible } = binding;
963
- return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
964
- } finally {
965
- lock.close();
966
- }
967
- }
968
-
969
- // apps/client/main.ts
970
- import { parseArgs as parseArgs3 } from "node:util";
971
- import { mkdir as mkdir7, realpath as realpath6 } from "node:fs/promises";
972
- import { homedir as homedir3 } from "node:os";
973
- import { join as join11, resolve as resolve6 } from "node:path";
974
-
975
- // apps/client/state.ts
976
- import { randomUUID as randomUUID3 } from "node:crypto";
977
- import { join as join3 } from "node:path";
978
- import { z as z8 } from "zod";
979
-
980
- // apps/client/sessions/state.ts
981
- var sessionClientMigration = String.raw`
982
- CREATE TABLE session_sources (
983
- root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
984
- session_key TEXT, revision INTEGER NOT NULL DEFAULT 0, hash TEXT, signature TEXT,
985
- checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0,
986
- pending_json TEXT, pending_signature TEXT, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
987
- PRIMARY KEY(root, path), UNIQUE(root, session_key)
988
- );
989
- CREATE INDEX session_source_scheduling ON session_sources(root, checked_at, path);
990
- CREATE TABLE session_discovery (
991
- root TEXT NOT NULL REFERENCES connections(root), directory TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
992
- PRIMARY KEY(root, directory)
993
- );
994
- CREATE TABLE session_discovery_cache (
995
- root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
996
- );
997
- `;
998
- var SessionState = class {
999
- constructor(state, root) {
1000
- this.state = state;
1001
- this.root = root;
1002
- }
1003
- state;
1004
- root;
1005
- track(agent, path, projectDir, adoptExisting = false) {
1006
- const prior = this.get(path);
1007
- if (prior && (prior.agent !== agent || prior.projectDir !== projectDir))
1008
- throw new Error("Source is registered with different settings; untrack it first.");
1009
- this.state.db.prepare(
1010
- `INSERT INTO session_sources(root,path,agent,project_dir,adopt_existing) VALUES (?,?,?,?,?)
1011
- ON CONFLICT(root,path) DO UPDATE SET adopt_existing=MAX(adopt_existing,excluded.adopt_existing)`
1012
- ).run(this.root, path, agent, projectDir, Number(adoptExisting));
1216
+ state;
1217
+ root;
1218
+ track(agent, path, projectDir, adoptExisting = false) {
1219
+ const prior = this.get(path);
1220
+ if (prior && (prior.agent !== agent || prior.projectDir !== projectDir))
1221
+ throw new Error("Source is registered with different settings; untrack it first.");
1222
+ this.state.db.prepare(
1223
+ `INSERT INTO session_sources(root,path,agent,project_dir,adopt_existing) VALUES (?,?,?,?,?)
1224
+ ON CONFLICT(root,path) DO UPDATE SET adopt_existing=MAX(adopt_existing,excluded.adopt_existing)`
1225
+ ).run(this.root, path, agent, projectDir, Number(adoptExisting));
1013
1226
  }
1014
1227
  get(path) {
1015
1228
  return this.sources(path)[0];
@@ -1115,7 +1328,8 @@ var SyncState = class {
1115
1328
  generation: Number(row.generation),
1116
1329
  observedAt: Number(row.observed_at),
1117
1330
  hash: row.hash === null ? null : String(row.hash),
1118
- verifiedAt: Number(row.verified_at)
1331
+ verifiedAt: Number(row.verified_at),
1332
+ seenLocal: Boolean(row.seen_local)
1119
1333
  }));
1120
1334
  }
1121
1335
  audit(before, limit) {
@@ -1133,7 +1347,8 @@ var SyncState = class {
1133
1347
  generation: Number(row.generation),
1134
1348
  observedAt: Number(row.observed_at),
1135
1349
  hash: row.hash === null ? null : String(row.hash),
1136
- verifiedAt: Number(row.verified_at)
1350
+ verifiedAt: Number(row.verified_at),
1351
+ seenLocal: Boolean(row.seen_local)
1137
1352
  } : void 0;
1138
1353
  }
1139
1354
  restartWaiting(now) {
@@ -1152,11 +1367,13 @@ var SyncState = class {
1152
1367
  const prior = this.get(path);
1153
1368
  if (!prior) {
1154
1369
  if (signature2 !== null)
1155
- 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);
1156
1373
  } else if (signature2 !== prior.signature) {
1157
1374
  this.db.prepare(
1158
- "UPDATE file_index SET signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
1159
- ).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);
1160
1377
  } else if (prior.eventPending) {
1161
1378
  this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
1162
1379
  }
@@ -1230,7 +1447,7 @@ var LocalBindingSchema = BindingSchema.extend({
1230
1447
  var ClientState = class {
1231
1448
  db;
1232
1449
  constructor(directory) {
1233
- this.db = openDatabase(join3(directory, "client.sqlite3"), [
1450
+ this.db = openDatabase(join4(directory, "client.sqlite3"), [
1234
1451
  String.raw`
1235
1452
  CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
1236
1453
  `,
@@ -1242,13 +1459,27 @@ var ClientState = class {
1242
1459
  syncMigration,
1243
1460
  sessionClientMigration,
1244
1461
  projectClientMigration,
1245
- controlClientMigration
1462
+ controlClientMigration,
1463
+ runtimeMigration
1246
1464
  ]);
1247
1465
  }
1248
1466
  close() {
1249
1467
  this.db.close();
1250
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
+ }
1251
1481
  deviceFor(root) {
1482
+ this.assertRootAvailable(root);
1252
1483
  this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
1253
1484
  return String(
1254
1485
  this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
@@ -1259,6 +1490,7 @@ var ClientState = class {
1259
1490
  return row?.binding_json ? LocalBindingSchema.parse(JSON.parse(String(row.binding_json))) : void 0;
1260
1491
  }
1261
1492
  saveBinding(root, serverUrl, token, binding) {
1493
+ this.assertRootAvailable(root);
1262
1494
  const prior = this.binding(root);
1263
1495
  if (prior && (prior.workspaceId !== binding.workspaceId || prior.memberId !== binding.memberId || prior.serverUrl !== serverUrl)) {
1264
1496
  throw new Error(
@@ -1312,10 +1544,10 @@ var ClientState = class {
1312
1544
  this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
1313
1545
  });
1314
1546
  }
1315
- acknowledge(root, result) {
1547
+ acknowledge(root, result2) {
1316
1548
  transaction(this.db, () => {
1317
1549
  const pending = this.pending(root);
1318
- 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)
1319
1551
  throw new Error("Unexpected publication acknowledgement.");
1320
1552
  for (const change of pending.batch.changes) {
1321
1553
  if (change.kind === "delete")
@@ -1325,7 +1557,7 @@ var ClientState = class {
1325
1557
  "INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
1326
1558
  ).run(root, change.path, change.sha256, change.size);
1327
1559
  }
1328
- 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);
1329
1561
  this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
1330
1562
  });
1331
1563
  }
@@ -1340,22 +1572,15 @@ var ClientState = class {
1340
1572
  }
1341
1573
  };
1342
1574
 
1343
- // apps/client/sessions/command.ts
1344
- import { createHash as createHash6 } from "node:crypto";
1345
- import { realpath as realpath3 } from "node:fs/promises";
1346
- import { homedir } from "node:os";
1347
- import { dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
1348
- import { parseArgs } from "node:util";
1349
-
1350
1575
  // apps/client/state-location.ts
1351
- import { mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
1352
- 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";
1353
1578
  async function stateLocation(root, directory) {
1354
- await mkdir3(resolve(directory), { recursive: true, mode: 448 });
1355
- const canonical = await realpath2(directory);
1579
+ await mkdir3(resolve2(directory), { recursive: true, mode: 448 });
1580
+ const canonical = await realpath3(directory);
1356
1581
  const contains = (parent, child) => {
1357
- const path = relative2(parent, child);
1358
- return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
1582
+ const path = relative3(parent, child);
1583
+ return path === "" || !isAbsolute2(path) && path !== ".." && !path.startsWith(`..${sep3}`);
1359
1584
  };
1360
1585
  if (contains(root, canonical) || contains(canonical, root))
1361
1586
  throw new Error(
@@ -1367,7 +1592,7 @@ async function stateLocation(root, directory) {
1367
1592
  // apps/client/sessions/hooks.ts
1368
1593
  import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
1369
1594
  import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
1370
- import { isAbsolute as isAbsolute2, join as join4 } from "node:path";
1595
+ import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
1371
1596
  import { z as z9 } from "zod";
1372
1597
 
1373
1598
  // apps/client/sessions/normalize.ts
@@ -1488,17 +1713,17 @@ function normalizeJsonl(agent, text) {
1488
1713
  // apps/client/sessions/hooks.ts
1489
1714
  var HintSchema = z9.object({
1490
1715
  agent: AgentSchema,
1491
- path: z9.string().min(1).max(4096).refine(isAbsolute2),
1492
- 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)
1493
1718
  });
1494
1719
  function inbox(directory, root) {
1495
- return join4(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
1720
+ return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
1496
1721
  }
1497
1722
  async function recordHint(directory, root, input) {
1498
1723
  const hint = HintSchema.parse(input);
1499
1724
  const targetDir = inbox(directory, root);
1500
1725
  await mkdir4(targetDir, { recursive: true, mode: 448 });
1501
- const target = join4(
1726
+ const target = join5(
1502
1727
  targetDir,
1503
1728
  `${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
1504
1729
  );
@@ -1524,7 +1749,7 @@ async function consumeHints(directory, root, sessions) {
1524
1749
  for await (const entry of dir) {
1525
1750
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
1526
1751
  if (count++ >= 50) break;
1527
- const path = join4(inbox(directory, root), entry.name);
1752
+ const path = join5(inbox(directory, root), entry.name);
1528
1753
  try {
1529
1754
  const handle = await open2(path, "r");
1530
1755
  let body;
@@ -1590,8 +1815,8 @@ async function runSessionCommand(args) {
1590
1815
  const { values } = parseArgs({
1591
1816
  args: args.slice(1),
1592
1817
  options: {
1593
- root: { type: "string", default: resolve2("workspaces") },
1594
- "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") },
1595
1820
  agent: { type: "string" },
1596
1821
  file: { type: "string" },
1597
1822
  directory: { type: "string" },
@@ -1610,16 +1835,16 @@ async function runSessionCommand(args) {
1610
1835
  process.stdout.write(help);
1611
1836
  return 0;
1612
1837
  }
1613
- const root = await realpath3(resolve2(values.root));
1838
+ const root = await realpath4(resolve3(values.root));
1614
1839
  const directory = await stateLocation(root, values["state-dir"]);
1615
- const projectDir = await realpath3(resolve2(values["project-dir"] ?? dirname3(root)));
1840
+ const projectDir = await realpath4(resolve3(values["project-dir"] ?? dirname4(root)));
1616
1841
  if (command === "hook") {
1617
1842
  const payload = values.file ? {} : await hookPayload();
1618
1843
  const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
1619
1844
  if (typeof file === "string" && file)
1620
1845
  await recordHint(directory, root, {
1621
1846
  agent: AgentSchema.parse(values.agent),
1622
- path: resolve2(file),
1847
+ path: resolve3(file),
1623
1848
  projectDir
1624
1849
  });
1625
1850
  return 0;
@@ -1633,7 +1858,7 @@ async function runSessionCommand(args) {
1633
1858
  let output;
1634
1859
  if (command === "track" || command === "untrack") {
1635
1860
  if (!values.file) throw new Error("--file is required.");
1636
- const path = resolve2(values.file);
1861
+ const path = resolve3(values.file);
1637
1862
  if (command === "track")
1638
1863
  sessions.track(
1639
1864
  AgentSchema.parse(values.agent),
@@ -1646,23 +1871,23 @@ async function runSessionCommand(args) {
1646
1871
  } else if (command === "discover" || command === "undiscover") {
1647
1872
  if (command === "undiscover") {
1648
1873
  if (!values.directory) throw new Error("--directory is required.");
1649
- 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)));
1650
1875
  } else {
1651
1876
  const agent = AgentSchema.parse(values.agent);
1652
1877
  const defaults = {
1653
- "claude-code": join5(homedir(), ".claude", "projects"),
1654
- codex: join5(process.env.CODEX_HOME ?? join5(homedir(), ".codex"), "sessions"),
1655
- cursor: join5(
1656
- 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"),
1657
1882
  "chats",
1658
1883
  createHash6("md5").update(projectDir).digest("hex")
1659
1884
  ),
1660
- 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"),
1661
1886
  dsh: void 0
1662
1887
  };
1663
1888
  const path = values.directory ?? defaults[agent];
1664
1889
  if (!path) throw new Error("DSH requires its configured persistence --directory.");
1665
- sessions.discover(agent, await realpath3(resolve2(path)), projectDir);
1890
+ sessions.discover(agent, await realpath4(resolve3(path)), projectDir);
1666
1891
  }
1667
1892
  output = sessions.directories();
1668
1893
  } else if (command === "sources")
@@ -1697,538 +1922,551 @@ async function runSessionCommand(args) {
1697
1922
  }
1698
1923
 
1699
1924
  // apps/client/sync-command.ts
1700
- import { realpath as realpath5 } from "node:fs/promises";
1701
- import { homedir as homedir2 } from "node:os";
1702
- import { join as join10, resolve as resolve5 } from "node:path";
1703
- 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
1704
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";
1705
1933
 
1706
- // apps/client/sessions/synchronizer.ts
1707
- import { randomUUID as randomUUID6 } from "node:crypto";
1708
- 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";
1709
1937
 
1710
- // packages/protocol/session-key.ts
1938
+ // apps/client/root-lock.ts
1711
1939
  import { createHash as createHash7 } from "node:crypto";
1712
- function sessionKey(agent, nativeId) {
1713
- 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
+ }
1714
1957
  }
1715
1958
 
1716
- // packages/storage/local-blob-store.ts
1717
- import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
1718
- import { link, mkdir as mkdir5, open as open3, readFile as readFile2, unlink as unlink3 } from "node:fs/promises";
1719
- import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
1720
- function hash(content) {
1721
- return createHash8("sha256").update(content).digest("hex");
1722
- }
1723
- function hasCode(error, code) {
1724
- return error instanceof Error && "code" in error && error.code === code;
1725
- }
1726
- var BlobIntegrityError = class extends Error {
1727
- constructor(sha256) {
1728
- super(`Stored content failed integrity verification: ${sha256}`);
1729
- this.sha256 = sha256;
1730
- this.name = "BlobIntegrityError";
1731
- }
1732
- sha256;
1733
- };
1734
- var LocalBlobStore = class {
1735
- root;
1736
- constructor(root) {
1737
- this.root = resolve3(root);
1738
- }
1739
- async put(content) {
1740
- const bytes = Buffer.from(content);
1741
- const sha256 = hash(bytes);
1742
- const target = this.pathFor(sha256);
1743
- await mkdir5(dirname4(target), { recursive: true, mode: 448 });
1744
- const temporary = `${target}.${randomUUID5()}.tmp`;
1745
- const file = await open3(temporary, "wx", 384);
1746
- try {
1747
- try {
1748
- await file.writeFile(bytes);
1749
- await file.sync();
1750
- } finally {
1751
- await file.close();
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
+ }
1752
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")) {
1753
2023
  try {
1754
- await link(temporary, target);
2024
+ await applyProjectFile(project, await client.projectConfig());
1755
2025
  } catch (error) {
1756
- if (!hasCode(error, "EEXIST")) throw error;
1757
- await this.get(sha256);
1758
- }
1759
- if (process.platform !== "win32") {
1760
- let directory = dirname4(target);
1761
- for (; ; ) {
1762
- const handle = await open3(directory, "r");
1763
- try {
1764
- await handle.sync();
1765
- } finally {
1766
- await handle.close();
1767
- }
1768
- if (directory === dirname4(this.root)) break;
1769
- directory = dirname4(directory);
1770
- }
2026
+ if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
2027
+ throw error;
1771
2028
  }
1772
- } finally {
1773
- await unlink3(temporary);
1774
2029
  }
1775
- return { sha256, size: bytes.byteLength };
1776
- }
1777
- async get(sha256) {
1778
- const target = this.pathFor(sha256);
1779
- let content;
1780
- try {
1781
- content = await readFile2(target);
1782
- } catch (error) {
1783
- if (hasCode(error, "ENOENT")) return void 0;
1784
- throw error;
1785
- }
1786
- if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
1787
- return content;
1788
- }
1789
- pathFor(sha256) {
1790
- if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
1791
- return join6(this.root, "sha256", sha256.slice(0, 2), sha256);
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();
1792
2037
  }
1793
- };
1794
-
1795
- // apps/client/sessions/source.ts
1796
- import { open as open4, stat, realpath as realpath4 } from "node:fs/promises";
1797
- import { relative as relative3, isAbsolute as isAbsolute3, sep as sep3 } from "node:path";
1798
- import * as zlib from "node:zlib";
2038
+ }
1799
2039
 
1800
- // apps/client/sessions/cursor.ts
1801
- import { createHash as createHash9 } from "node:crypto";
1802
- import { basename, dirname as dirname5, resolve as resolve4 } from "node:path";
1803
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1804
- import { fileURLToPath } from "node:url";
1805
- function fields(bytes) {
1806
- const data = Buffer.from(bytes);
1807
- let offset = 0;
1808
- const varint = () => {
1809
- let value = 0n;
1810
- for (let i = 0; i < 10; i++) {
1811
- if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
1812
- const byte = data[offset++];
1813
- value |= BigInt(byte & 127) << BigInt(i * 7);
1814
- if (byte < 128) return value;
1815
- }
1816
- throw new Error("Invalid Cursor protobuf integer.");
1817
- };
1818
- const result = /* @__PURE__ */ new Map();
1819
- while (offset < data.length) {
1820
- const tag = varint();
1821
- const field = Number(tag >> 3n);
1822
- if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
1823
- const wire = Number(tag & 7n);
1824
- if (wire === 0) varint();
1825
- else if (wire === 1) offset += 8;
1826
- else if (wire === 5) offset += 4;
1827
- else if (wire === 2) {
1828
- const length = Number(varint());
1829
- if (!Number.isSafeInteger(length) || length > data.length - offset)
1830
- throw new Error("Truncated Cursor protobuf value.");
1831
- const values = result.get(field) ?? [];
1832
- values.push(data.subarray(offset, offset + length));
1833
- result.set(field, values);
1834
- offset += length;
1835
- } else throw new Error("Unsupported Cursor protobuf wire type.");
1836
- if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
1837
- }
1838
- return result;
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
+ );
1839
2055
  }
1840
- function readCursor(path, projectDir) {
1841
- const db = new DatabaseSync2(path, { readOnly: true });
1842
- try {
1843
- db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
1844
- if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
1845
- throw new Error("Unsupported Cursor CLI store version (expected 1).");
1846
- const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
1847
- const hex = row?.value;
1848
- if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
1849
- throw new Error("Invalid Cursor metadata.");
1850
- const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
1851
- if (typeof meta.agentId !== "string" || meta.agentId !== basename(dirname5(path)) || meta.subagentInfo != null)
1852
- throw new Error("Invalid or delegated Cursor session identity.");
1853
- let budget = 64 * 1024 * 1024;
1854
- const blob = (id) => {
1855
- if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
1856
- const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
1857
- if (!(value instanceof Uint8Array))
1858
- throw new Error("Cursor history missing or exceeds 64 MiB.");
1859
- const bytes = Buffer.from(value);
1860
- budget -= bytes.length;
1861
- if (createHash9("sha256").update(bytes).digest("hex") !== id)
1862
- throw new Error("Cursor blob failed integrity verification.");
1863
- return bytes;
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);
1864
2077
  };
1865
- const root = fields(blob(String(meta.latestRootBlobId)));
1866
- const bucket = basename(dirname5(dirname5(path)));
1867
- const md5 = (cwd2) => createHash9("md5").update(resolve4(cwd2)).digest("hex");
1868
- let cwd = md5(projectDir) === bucket ? projectDir : void 0;
1869
- for (const uri of root.get(9) ?? []) {
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;
1870
2094
  try {
1871
- const native = fileURLToPath(uri.toString("utf8"));
1872
- if (md5(native) === bucket) {
1873
- cwd = native;
1874
- break;
1875
- }
1876
- } catch {
1877
- }
1878
- }
1879
- if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
1880
- const refs = [];
1881
- for (const archive of root.get(13) ?? root.get(11) ?? [])
1882
- refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
1883
- refs.push(...root.get(1) ?? []);
1884
- if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
1885
- const messages = [];
1886
- const counts = /* @__PURE__ */ new Map();
1887
- for (const ref of refs) {
1888
- const key = ref.toString("hex");
1889
- const message2 = object(
1890
- JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
1891
- );
1892
- const cursor = object(object(message2.providerOptions).cursor);
1893
- if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
1894
- let text = textContent(message2.content);
1895
- if (message2.role === "user") {
1896
- if (!cursor.requestId) continue;
1897
- const start = text.indexOf("<user_query>");
1898
- const end = text.lastIndexOf("</user_query>");
1899
- if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
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);
1900
2100
  }
1901
- if (!text.trim()) continue;
1902
- const id = createHash9("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
1903
- const count = (counts.get(id) ?? 0) + 1;
1904
- counts.set(id, count);
1905
- messages.push({
1906
- id: `${id}#${count}`,
1907
- role: message2.role,
1908
- text,
1909
- source: `blob:${key}`
1910
- });
1911
- }
1912
- return SessionSnapshotSchema.parse({
1913
- formatVersion: 1,
1914
- adapterVersion: 1,
1915
- agent: "cursor",
1916
- nativeId: meta.agentId,
1917
- cwd,
1918
- messages
1919
2101
  });
1920
- } finally {
1921
- db.close();
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;
1922
2113
  }
1923
2114
  }
1924
2115
 
1925
- // apps/client/sessions/source.ts
1926
- var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
1927
- async function sourceSignature(path, agent) {
1928
- const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
1929
- const signatures = [];
1930
- for (const file of paths) {
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 (; ; ) {
1931
2183
  try {
1932
- const s = await stat(file, { bigint: true });
1933
- if (!s.isFile()) throw new Error("Session source must be a regular file.");
1934
- signatures.push([
1935
- String(s.dev),
1936
- String(s.ino),
1937
- String(s.size),
1938
- String(s.mtimeNs),
1939
- String(s.ctimeNs)
1940
- ]);
2184
+ await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
2185
+ return;
1941
2186
  } catch (error) {
1942
- if (file !== path && error.code === "ENOENT")
1943
- signatures.push(null);
1944
- else throw error;
2187
+ if (error.code !== 5 || Date.now() >= deadline) throw error;
2188
+ await setTimeout3(250);
1945
2189
  }
1946
2190
  }
1947
- return JSON.stringify(signatures);
1948
2191
  }
1949
- async function readSource(agent, path, projectDir) {
1950
- const signature2 = await sourceSignature(path, agent);
1951
- let result;
1952
- if (agent === "cursor") result = { snapshot: readCursor(path, projectDir), incomplete: false };
1953
- else {
1954
- const handle = await open4(path, "r");
1955
- let bytes;
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;
1956
2212
  try {
1957
- const size = (await handle.stat()).size;
1958
- if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
1959
- bytes = Buffer.alloc(size + 1);
1960
- let offset = 0;
1961
- while (offset < bytes.length) {
1962
- const read = await handle.read(bytes, offset, bytes.length - offset, offset);
1963
- if (!read.bytesRead) break;
1964
- offset += read.bytesRead;
1965
- }
1966
- bytes = bytes.subarray(0, offset);
1967
- if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
1968
- } finally {
1969
- await handle.close();
2213
+ await exec2("launchctl", ["print", target]);
2214
+ loaded = true;
2215
+ } catch {
1970
2216
  }
1971
- if (agent === "dsh" && path.endsWith(".zstd")) {
1972
- const decompress = zlib.zstdDecompressSync;
1973
- if (!decompress)
1974
- throw new Error(
1975
- "Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
1976
- );
1977
- bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
2217
+ if (loaded && (old !== definition.content || alive?.version !== version)) {
2218
+ await exec2("launchctl", ["bootout", target]);
2219
+ loaded = false;
1978
2220
  }
1979
- result = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
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
+ ]);
1980
2231
  }
1981
- if (signature2 !== await sourceSignature(path, agent))
1982
- throw new Error("Session source changed while reading; retry next cycle.");
1983
- const cwd = await realpath4(result.snapshot.cwd);
1984
- const project = await realpath4(projectDir);
1985
- const rel = relative3(project, cwd);
1986
- if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
1987
- throw new Error("Session cwd is outside the registered project.");
1988
- return { ...result, signature: signature2 };
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" });
1989
2244
  }
1990
2245
 
1991
- // apps/client/sessions/discovery.ts
1992
- import { opendir as opendir2 } from "node:fs/promises";
1993
- import { join as join7 } from "node:path";
1994
- async function* candidates(directory, agent) {
1995
- const queue = [directory];
1996
- while (queue.length) {
1997
- const current = queue.shift();
1998
- const dir = await opendir2(current);
1999
- for await (const entry of dir) {
2000
- const path = join7(current, entry.name);
2001
- if (entry.isDirectory()) queue.push(path);
2002
- if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
2003
- yield path;
2004
- else yield null;
2005
- }
2006
- }
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;
2007
2253
  }
2008
- var SessionDiscovery = class {
2009
- constructor(state) {
2010
- this.state = state;
2011
- }
2012
- state;
2013
- scans = /* @__PURE__ */ new Map();
2014
- async cycle() {
2015
- const issues = [];
2016
- let entries = 500;
2017
- let reads = 5;
2018
- const configs = this.state.directories();
2019
- for (const [directory, scan] of this.scans) {
2020
- if (!configs.some((config) => config.directory === directory)) {
2021
- await scan.iterator.return(void 0);
2022
- this.scans.delete(directory);
2023
- }
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" }
2024
2282
  }
2025
- for (const config of configs) {
2026
- let scan = this.scans.get(config.directory);
2027
- if (!scan) {
2028
- scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
2029
- this.scans.set(config.directory, scan);
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
+ );
2030
2336
  }
2031
- if (scan.nextAt > Date.now()) continue;
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 });
2032
2348
  try {
2033
- while (entries > 0 && reads > 0) {
2034
- entries--;
2035
- const entry = await scan.iterator.next();
2036
- if (entry.done) {
2037
- scan.iterator = candidates(config.directory, config.agent);
2038
- scan.nextAt = Date.now() + 3e5;
2039
- break;
2040
- }
2041
- const path = entry.value;
2042
- if (!path || this.state.get(path)) continue;
2043
- const signature2 = await sourceSignature(path, config.agent);
2044
- const db = this.state.state.db;
2045
- if (db.prepare(
2046
- "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
2047
- ).get(this.state.root, path, signature2))
2048
- continue;
2049
- reads--;
2050
- try {
2051
- const result = await readSource(config.agent, path, config.projectDir);
2052
- if (result.incomplete) continue;
2053
- this.state.track(config.agent, path, config.projectDir);
2054
- } catch (error) {
2055
- const message2 = error instanceof Error ? error.message : "Discovery failed.";
2056
- if (!message2.includes("outside the registered project"))
2057
- issues.push(`${path}: ${message2}`);
2058
- if (!message2.includes("outside the registered project")) continue;
2059
- }
2060
- db.prepare(
2061
- "INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
2062
- ).run(this.state.root, path, signature2);
2063
- }
2064
- } catch (error) {
2065
- issues.push(
2066
- `${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
2067
- );
2068
- await scan.iterator.return(void 0);
2069
- scan.iterator = candidates(config.directory, config.agent);
2070
- scan.nextAt = Date.now() + 3e5;
2349
+ member = (await rl.question("Member ID: ")).trim();
2350
+ } finally {
2351
+ rl.close();
2071
2352
  }
2072
2353
  }
2073
- return issues;
2074
- }
2075
- async close() {
2076
- for (const scan of this.scans.values()) await scan.iterator.return(void 0);
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();
2077
2381
  }
2078
- };
2382
+ }
2079
2383
 
2080
- // apps/client/sessions/synchronizer.ts
2081
- var SessionSynchronizer = class {
2082
- constructor(state, root, directory, signal) {
2083
- this.root = root;
2084
- this.directory = directory;
2085
- const binding = state.binding(root);
2086
- if (!binding) throw new Error("Connect this root before collecting sessions.");
2087
- this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
2088
- this.blobs = new LocalBlobStore(join8(directory, "blobs"));
2089
- this.index = new SessionState(state, root);
2090
- this.discovery = new SessionDiscovery(this.index);
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
+ }
2401
+ var BlobIntegrityError = class extends Error {
2402
+ constructor(sha256) {
2403
+ super(`Stored content failed integrity verification: ${sha256}`);
2404
+ this.sha256 = sha256;
2405
+ this.name = "BlobIntegrityError";
2091
2406
  }
2407
+ sha256;
2408
+ };
2409
+ var LocalBlobStore = class {
2092
2410
  root;
2093
- directory;
2094
- index;
2095
- client;
2096
- blobs;
2097
- discovery;
2098
- capable = false;
2099
- async cycle() {
2100
- const report = { published: 0, checked: 0, issues: [] };
2101
- report.issues.push(...await consumeHints(this.directory, this.root, this.index));
2102
- report.issues.push(...await this.discovery.cycle());
2103
- const sources = this.index.sources(void 0, 200);
2104
- if (sources.length && !this.capable) {
2105
- const info = await this.client.serverInfo();
2106
- if (!["blobs", "session-revisions"].every(
2107
- (capability) => info.capabilities.includes(capability)
2108
- ))
2109
- throw new Error("Server does not support session publication. Upgrade the server first.");
2110
- this.capable = true;
2111
- }
2112
- let reads = 5;
2113
- let uploads = 5;
2114
- for (const source of sources) {
2115
- if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
2116
- report.checked++;
2117
- this.index.checked(source.path, source.lastError);
2411
+ constructor(root) {
2412
+ this.root = resolve6(root);
2413
+ }
2414
+ async put(content) {
2415
+ const bytes = Buffer.from(content);
2416
+ const sha256 = hash(bytes);
2417
+ const target = this.pathFor(sha256);
2418
+ await mkdir8(dirname6(target), { recursive: true, mode: 448 });
2419
+ const temporary = `${target}.${randomUUID5()}.tmp`;
2420
+ const file = await open3(temporary, "wx", 384);
2421
+ try {
2118
2422
  try {
2119
- if (source.pending) {
2120
- if (source.retryAt > Date.now()) {
2121
- if (source.lastError)
2122
- report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
2123
- continue;
2124
- }
2125
- uploads--;
2126
- await this.flush(source);
2127
- report.published++;
2128
- continue;
2129
- }
2130
- const signature2 = await sourceSignature(source.path, source.agent);
2131
- if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
2132
- this.index.checked(source.path);
2133
- continue;
2134
- }
2135
- reads--;
2136
- const observed = await readSource(source.agent, source.path, source.projectDir);
2137
- if (observed.incomplete)
2138
- throw new Error("Final session record is incomplete; waiting for the next save.");
2139
- if (!observed.snapshot.messages.length) {
2140
- this.index.checked(source.path);
2141
- continue;
2142
- }
2143
- const key = sessionKey(source.agent, observed.snapshot.nativeId);
2144
- if (source.key && source.key !== key)
2145
- throw new Error(
2146
- "Native session identity changed at this path; register the new source explicitly."
2147
- );
2148
- const bytes = Buffer.from(JSON.stringify(observed.snapshot));
2149
- if (bytes.length > MAX_BLOB_BYTES)
2150
- throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
2151
- const blob = await this.blobs.put(bytes);
2152
- let revision = source.revision;
2153
- if (!source.key) {
2423
+ await file.writeFile(bytes);
2424
+ await file.sync();
2425
+ } finally {
2426
+ await file.close();
2427
+ }
2428
+ try {
2429
+ await link(temporary, target);
2430
+ } catch (error) {
2431
+ if (!hasCode(error, "EEXIST")) throw error;
2432
+ await this.get(sha256);
2433
+ }
2434
+ if (process.platform !== "win32") {
2435
+ let directory = dirname6(target);
2436
+ for (; ; ) {
2437
+ const handle = await open3(directory, "r");
2154
2438
  try {
2155
- const owner = this.index.state.binding(this.root).memberId;
2156
- const current = await this.client.session(owner, key);
2157
- if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
2158
- throw new Error(
2159
- "Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
2160
- );
2161
- revision = current.revision;
2162
- } catch (error) {
2163
- if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
2164
- throw error;
2439
+ await handle.sync();
2440
+ } finally {
2441
+ await handle.close();
2165
2442
  }
2166
- this.index.baseline(source.path, key, revision);
2167
- }
2168
- if (blob.sha256 === source.hash) {
2169
- this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
2170
- continue;
2443
+ if (directory === dirname6(this.root)) break;
2444
+ directory = dirname6(directory);
2171
2445
  }
2172
- this.index.stage(
2173
- source.path,
2174
- { operationId: randomUUID6(), sessionKey: key, parentRevision: revision, blob },
2175
- observed.signature
2176
- );
2177
- uploads--;
2178
- await this.flush(this.index.get(source.path));
2179
- report.published++;
2180
- } catch (error) {
2181
- const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
2182
- this.index.checked(source.path, message2);
2183
- report.issues.push(`${source.path}: ${message2}`);
2184
2446
  }
2447
+ } finally {
2448
+ await unlink3(temporary);
2185
2449
  }
2186
- return report;
2450
+ return { sha256, size: bytes.byteLength };
2187
2451
  }
2188
- async flush(source) {
2189
- const request = source.pending;
2452
+ async get(sha256) {
2453
+ const target = this.pathFor(sha256);
2454
+ let content;
2190
2455
  try {
2191
- const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
2192
- if (missing2.some((hash2) => hash2 !== request.blob.sha256))
2193
- throw new Error("Server requested unrelated session content.");
2194
- if (missing2.length) {
2195
- const bytes = await this.blobs.get(request.blob.sha256);
2196
- if (!bytes) throw new Error("Queued session snapshot is missing.");
2197
- const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
2198
- if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
2199
- throw new Error("Server acknowledged different session content.");
2200
- }
2201
- const result = await this.client.commitSession(request);
2202
- if (result.operationId !== request.operationId || result.revision < request.parentRevision || result.revision > request.parentRevision + 1)
2203
- throw new Error("Invalid session acknowledgement.");
2204
- this.index.acknowledge(
2205
- source.path,
2206
- result.revision,
2207
- request.blob.sha256,
2208
- source.pendingSignature
2209
- );
2456
+ content = await readFile3(target);
2210
2457
  } catch (error) {
2211
- const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2212
- this.index.postpone(
2213
- source,
2214
- error instanceof Error ? error.message : "Upload failed.",
2215
- blocked
2216
- );
2458
+ if (hasCode(error, "ENOENT")) return void 0;
2217
2459
  throw error;
2218
2460
  }
2461
+ if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
2462
+ return content;
2219
2463
  }
2220
- async close() {
2221
- await this.discovery.close();
2464
+ pathFor(sha256) {
2465
+ if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
2466
+ return join11(this.root, "sha256", sha256.slice(0, 2), sha256);
2222
2467
  }
2223
2468
  };
2224
2469
 
2225
- // apps/client/files/synchronizer.ts
2226
- import { randomUUID as randomUUID7 } from "node:crypto";
2227
- import { watch } from "node:fs";
2228
- import { lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
2229
- import { join as join9, sep as sep4 } from "node:path";
2230
- import { performance } from "node:perf_hooks";
2231
-
2232
2470
  // apps/client/publication.ts
2233
2471
  var PublicationQueue = class {
2234
2472
  constructor(state, blobs, transport) {
@@ -2255,8 +2493,8 @@ var PublicationQueue = class {
2255
2493
  if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
2256
2494
  const content = await this.blobs.get(hash2);
2257
2495
  if (!content) throw new Error("A queued content snapshot is missing from local storage.");
2258
- const result = await this.transport.uploadBlob(hash2, content);
2259
- if (result.sha256 !== hash2 || result.size !== content.byteLength)
2496
+ const result2 = await this.transport.uploadBlob(hash2, content);
2497
+ if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
2260
2498
  throw new Error("Server acknowledged different content.");
2261
2499
  }
2262
2500
  this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
@@ -2285,10 +2523,10 @@ var FileSynchronizer = class {
2285
2523
  if (!binding) throw new Error("Connect this root before synchronizing.");
2286
2524
  this.binding = binding;
2287
2525
  this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2288
- this.blobs = new LocalBlobStore(join9(stateDirectory, "blobs"));
2526
+ this.blobs = new LocalBlobStore(join12(stateDirectory, "blobs"));
2289
2527
  this.queue = new PublicationQueue(state, this.blobs, this.client);
2290
2528
  this.index = new SyncState(state, root);
2291
- this.ownRoot = join9(root, binding.memberId);
2529
+ this.ownRoot = join12(root, binding.memberId);
2292
2530
  const started = performance.now();
2293
2531
  const epoch = Date.now();
2294
2532
  this.clock = options.clock ?? (() => epoch + performance.now() - started);
@@ -2344,7 +2582,7 @@ var FileSynchronizer = class {
2344
2582
  try {
2345
2583
  try {
2346
2584
  await this.initializeAuthor();
2347
- const ownStat = await lstat2(await safePath(this.root, this.binding.memberId));
2585
+ const ownStat = await lstat4(await safePath(this.root, this.binding.memberId));
2348
2586
  if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
2349
2587
  throw new Error("Author directory is unavailable or is a symlink.");
2350
2588
  this.listen();
@@ -2403,11 +2641,7 @@ var FileSynchronizer = class {
2403
2641
  async initializeAuthor() {
2404
2642
  if (this.state.publication(this.root).initialized) return;
2405
2643
  const manifest = await this.manifest(this.binding.memberId);
2406
- if (manifest.revision > 0) {
2407
- if (!this.options.adoptBaseline)
2408
- throw new Error(
2409
- "Published history exists but the local baseline is missing. Verify local files, then use --adopt-baseline."
2410
- );
2644
+ if (manifest.revision > 0 && this.options.adoptBaseline) {
2411
2645
  for (const file of manifest.files) {
2412
2646
  const local = await readStable(this.ownRoot, file.path);
2413
2647
  if (local.sha256 !== file.sha256)
@@ -2415,12 +2649,11 @@ var FileSynchronizer = class {
2415
2649
  `Cannot adopt baseline: local ${file.path} differs from the published file.`
2416
2650
  );
2417
2651
  }
2418
- } else {
2419
- const ownerPath = await safePath(this.root, this.binding.memberId);
2420
- await mkdir6(ownerPath).catch((error) => {
2421
- if (error.code !== "EEXIST") throw error;
2422
- });
2423
2652
  }
2653
+ const ownerPath = await safePath(this.root, this.binding.memberId);
2654
+ await mkdir9(ownerPath).catch((error) => {
2655
+ if (error.code !== "EEXIST") throw error;
2656
+ });
2424
2657
  this.state.initializePublication(this.root, manifest.revision, manifest.files);
2425
2658
  }
2426
2659
  listen() {
@@ -2512,7 +2745,7 @@ var FileSynchronizer = class {
2512
2745
  this.index.event(row.path, this.clock());
2513
2746
  continue;
2514
2747
  }
2515
- if (baseline.has(row.path)) {
2748
+ if (baseline.has(row.path) && row.seenLocal) {
2516
2749
  changes.push({ kind: "delete", path: row.path });
2517
2750
  planned.delete(row.path);
2518
2751
  } else {
@@ -2526,7 +2759,12 @@ var FileSynchronizer = class {
2526
2759
  const collision = [...planned.keys()].some(
2527
2760
  (path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
2528
2761
  );
2529
- if (collision) continue;
2762
+ if (collision) {
2763
+ issues.push(
2764
+ `${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
2765
+ );
2766
+ continue;
2767
+ }
2530
2768
  const local = await readStable(this.ownRoot, row.path);
2531
2769
  bytes += local.bytes.length;
2532
2770
  if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
@@ -2537,245 +2775,1027 @@ var FileSynchronizer = class {
2537
2775
  this.index.clean(row.path, row.generation, local.sha256, this.clock());
2538
2776
  continue;
2539
2777
  }
2540
- const ref = await this.blobs.put(local.bytes);
2541
- changes.push({ kind: "upsert", path: row.path, ...ref });
2542
- planned.set(row.path, ref);
2543
- snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
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 });
2782
+ } catch (error) {
2783
+ this.index.event(row.path, this.clock());
2784
+ issues.push(`${row.path}: ${message(error)}`);
2785
+ }
2786
+ }
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());
2798
+ }
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);
2834
+ }
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)))
2998
+ );
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
+ });
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();
3029
+ }
3030
+ }
3031
+
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) {
3038
+ try {
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
+ ]);
3048
+ } catch (error) {
3049
+ if (file !== path && error.code === "ENOENT")
3050
+ signatures.push(null);
3051
+ else throw error;
3052
+ }
3053
+ }
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;
3063
+ try {
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;
3072
+ }
3073
+ bytes = bytes.subarray(0, offset);
3074
+ if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
3075
+ } finally {
3076
+ await handle.close();
3077
+ }
3078
+ if (agent === "dsh" && path.endsWith(".zstd")) {
3079
+ const decompress = zlib.zstdDecompressSync;
3080
+ if (!decompress)
3081
+ throw new Error(
3082
+ "Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
3083
+ );
3084
+ bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
3085
+ }
3086
+ result2 = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
3087
+ }
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;
3112
+ }
3113
+ }
3114
+ }
3115
+ var SessionDiscovery = class {
3116
+ constructor(state) {
3117
+ this.state = state;
3118
+ }
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);
3130
+ }
3131
+ }
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;
3139
+ try {
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
+ }
3171
+ } catch (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;
3178
+ }
3179
+ }
3180
+ return issues;
3181
+ }
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);
3225
+ try {
3226
+ if (source.pending) {
3227
+ if (source.retryAt > Date.now()) {
3228
+ if (source.lastError)
3229
+ report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
3230
+ continue;
3231
+ }
3232
+ uploads--;
3233
+ await this.flush(source);
3234
+ report.published++;
3235
+ continue;
3236
+ }
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);
3240
+ continue;
3241
+ }
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);
3277
+ continue;
3278
+ }
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++;
2544
3287
  } catch (error) {
2545
- this.index.event(row.path, this.clock());
2546
- 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}`);
2547
3291
  }
2548
3292
  }
2549
- if (!changes.length) return;
2550
- const rootStat = await lstat2(this.ownRoot);
2551
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
2552
- throw new Error("Author directory disappeared before publication.");
2553
- this.state.enqueue(this.root, {
2554
- operationId: randomUUID7(),
2555
- parentRevision: this.state.publication(this.root).revision,
2556
- changes
2557
- });
2558
- for (const snapshot of snapshots)
2559
- this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
3293
+ return report;
2560
3294
  }
2561
- async receive(owner) {
2562
- if (!this.index.job(owner)) {
2563
- const revision = this.index.revision(owner);
2564
- if (revision === void 0) {
2565
- const manifest = await this.manifest(owner);
2566
- this.index.queue(
2567
- owner,
2568
- manifest.revision,
2569
- manifest.files.map((file) => ({
2570
- kind: "upsert",
2571
- path: file.path,
2572
- sha256: file.sha256,
2573
- size: file.size
2574
- }))
2575
- );
2576
- } else {
2577
- const delta = await this.client.fileChanges(owner, revision);
2578
- if (!delta.revisions.length) return 0;
2579
- const changes = /* @__PURE__ */ new Map();
2580
- let expected = revision + 1;
2581
- for (const entry of delta.revisions) {
2582
- if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
2583
- for (const change of entry.changes) changes.set(change.path, change);
2584
- }
2585
- const finalNames = new Set(
2586
- [...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
2587
- );
2588
- for (const change of changes.values()) {
2589
- if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
2590
- changes.delete(change.path);
2591
- }
2592
- const ordered = [...changes.values()].sort(
2593
- (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
2594
- );
2595
- 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.");
2596
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;
2597
3325
  }
2598
- const job = this.index.job(owner);
2599
- const ownerRoot = await safePath(this.root, owner);
2600
- await mkdir6(ownerRoot).catch((error) => {
2601
- 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;
2602
3389
  });
2603
- const ownerStat = await lstat2(ownerRoot);
2604
- if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
2605
- throw new Error("Mirror root is not an ordinary directory.");
2606
- let count = 0;
2607
- let bytes = 0;
2608
- for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
2609
- if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
2610
- break;
2611
- const change = job.changes[offset];
2612
- await this.apply(owner, ownerRoot, change);
2613
- this.index.applied(owner, change, offset + 1);
2614
- count++;
2615
- 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]);
2616
3412
  }
2617
- if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
2618
- return count;
3413
+ return { ...report, errors, control: this.control.enabled ? this.control.status : null };
2619
3414
  }
2620
- async apply(owner, root, change) {
2621
- const prior = this.index.received(owner, change.path);
2622
- const current = await metadata(root, change.path);
2623
- let local;
2624
- if (current && !current.directory) local = await readStable(root, change.path);
2625
- if (change.kind === "delete") {
2626
- if (!current) return;
2627
- if (current.directory && !prior) return;
2628
- if (!local || !prior || local.sha256 !== prior.sha256)
2629
- throw new Error(`Local mirror differs; preserving ${change.path}.`);
2630
- 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
+ }
2631
3477
  } else {
2632
- if (local?.sha256 === change.sha256) return;
2633
- if (local && (!prior || local.sha256 !== prior.sha256))
2634
- throw new Error(`Local mirror differs; preserving ${change.path}.`);
2635
- if (!current && prior && !this.options.restoreMissing)
2636
- throw new Error(
2637
- `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}`
2638
3486
  );
2639
- let content = await this.blobs.get(change.sha256);
2640
- if (!content) {
2641
- content = await this.client.downloadBlob(change.sha256);
2642
- await this.blobs.put(content);
2643
- }
2644
- if (content.byteLength !== change.size)
2645
- throw new Error(`Content size mismatch: ${change.path}`);
2646
- await replaceFile(root, change.path, content, local?.signature ?? null);
2647
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
+ })();
2648
3521
  }
2649
3522
  };
2650
- function message(error) {
2651
- return error instanceof Error ? error.message : String(error);
2652
- }
2653
3523
 
2654
3524
  // apps/client/sync-command.ts
2655
- var help2 = `Usage: teammy sync [options]
3525
+ var help2 = `Usage: teammy sync on | off [--root DIR]
2656
3526
 
2657
- --root DIR Shared root (default: ./workspaces)
2658
- --state-dir DIR Local state (default: ~/.teammy/client)
2659
- --once Run one check and exit; pending changes remain saved
2660
- --interval-ms N Check interval (default: 60000, minimum: 1000)
2661
- --settle-ms N Per-file quiet period (default: 60000)
2662
- --adopt-baseline Accept existing server history only if every published file matches locally
2663
- --restore-missing Restore missing mirror files; existing local edits are still preserved
2664
- --retry Retry file/session publications paused by a previous failure
2665
- --sessions-only Collect sessions without applying shared files
2666
- -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
2667
3532
 
2668
- Runs in the foreground until SIGINT/SIGTERM. Each run restarts the quiet period
2669
- for pending files; --once does not wait for edits to settle. --settle-ms 0 can be
2670
- 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.
2671
3536
  `;
2672
3537
  async function runSyncCommand(args, version) {
2673
- const { values } = parseArgs2({
2674
- args,
3538
+ const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
3539
+ const { values } = parseArgs3({
3540
+ args: action ? args.slice(1) : args,
2675
3541
  options: {
2676
- root: { type: "string", default: resolve5("workspaces") },
2677
- "state-dir": { type: "string", default: join10(homedir2(), ".teammy", "client") },
3542
+ ...locationOptions,
2678
3543
  once: { type: "boolean" },
3544
+ foreground: { type: "boolean" },
2679
3545
  "interval-ms": { type: "string" },
2680
3546
  "settle-ms": { type: "string" },
2681
3547
  "adopt-baseline": { type: "boolean" },
2682
3548
  "restore-missing": { type: "boolean" },
2683
3549
  retry: { type: "boolean" },
2684
- "sessions-only": { type: "boolean" },
2685
- help: { type: "boolean", short: "h" }
3550
+ "sessions-only": { type: "boolean" }
2686
3551
  }
2687
3552
  });
2688
- if (values.help) {
3553
+ if (values.help || !action && !values.once && !values.foreground) {
2689
3554
  process.stdout.write(help2);
2690
3555
  return 0;
2691
3556
  }
2692
- let intervalMs = Number(values["interval-ms"] ?? 6e4);
2693
- const settleMs = Number(values["settle-ms"] ?? 6e4);
2694
- if (!Number.isSafeInteger(intervalMs) || intervalMs < 1e3 || intervalMs > 864e5 || !Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > 864e5)
2695
- throw new Error("Invalid sync timing options.");
2696
- const root = await realpath5(resolve5(values.root));
2697
- const stateDirectory = await stateLocation(root, values["state-dir"]);
2698
- const lock = acquireRootLock(root, stateDirectory);
2699
- let state;
2700
- let synchronizer;
2701
- let sessions;
2702
- let control;
2703
- 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
+ });
2704
3758
  const stop = () => {
2705
- stopped.abort();
2706
- synchronizer?.close();
3759
+ closing = true;
3760
+ resolveStop();
2707
3761
  };
2708
3762
  try {
2709
- state = new ClientState(stateDirectory);
2710
- if (values.retry) state.retryPublication(root);
2711
- sessions = new SessionSynchronizer(state, root, stateDirectory, stopped.signal);
2712
- if (values.retry) sessions.index.retry();
2713
- if (!values["sessions-only"])
2714
- synchronizer = new FileSynchronizer(state, root, stateDirectory, {
2715
- settleMs,
2716
- adoptBaseline: Boolean(values["adopt-baseline"]),
2717
- 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();
2718
3772
  });
2719
- const overrides = {};
2720
- if (values["interval-ms"] !== void 0) overrides.checkIntervalSeconds = intervalMs / 1e3;
2721
- if (values["settle-ms"] !== void 0) overrides.fileSettleSeconds = settleMs / 1e3;
2722
- control = new ControlPlane(state, root, version, overrides, (policy) => {
2723
- intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
2724
- synchronizer?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
2725
3773
  });
2726
- await control.tick();
2727
- await synchronizer?.checkCapabilities();
2728
- if (!values.once) control.start();
3774
+ await chmod2(socketPath(directory), 384);
2729
3775
  process.once("SIGINT", stop);
2730
3776
  process.once("SIGTERM", stop);
2731
- do {
2732
- const report = synchronizer ? await synchronizer.cycle() : { issues: [], published: false, appliedFiles: 0, waitingFiles: 0 };
2733
- let sessionReport;
2734
- try {
2735
- sessionReport = await sessions.cycle();
2736
- } catch (error) {
2737
- sessionReport = {
2738
- published: 0,
2739
- checked: 0,
2740
- issues: [error instanceof Error ? error.message : "Session synchronization failed."]
2741
- };
2742
- }
2743
- control.updateSync({
2744
- checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2745
- published: report.published,
2746
- appliedFiles: report.appliedFiles,
2747
- pendingFiles: report.waitingFiles + (state.pending(root)?.batch.changes.length ?? 0),
2748
- sessionSources: Number(
2749
- state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(root).n
2750
- ),
2751
- errors: [...report.issues, ...sessionReport.issues].slice(0, 20).map((message2) => message2.slice(0, 1e3))
2752
- });
2753
- if (values.once && control.enabled) await control.tick();
2754
- const combined = {
2755
- ...report,
2756
- sessions: sessionReport,
2757
- control: control.enabled ? control.status : null
2758
- };
2759
- process.stdout.write(`${JSON.stringify(combined)}
2760
- `);
2761
- if (values.once)
2762
- return report.issues.length || sessionReport.issues.length || control.enabled && (control.status.configError || control.status.reportError) ? 1 : 0;
2763
- const finishedAt = Date.now();
2764
- while (!stopped.signal.aborted && Date.now() - finishedAt < intervalMs)
2765
- await setTimeout3(Math.min(1e3, intervalMs - (Date.now() - finishedAt)), void 0, {
2766
- signal: stopped.signal
2767
- }).catch((error) => {
2768
- if (!stopped.signal.aborted) throw error;
2769
- });
2770
- } while (!stopped.signal.aborted);
2771
- 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;
2772
3784
  } finally {
3785
+ closing = true;
3786
+ clearInterval(timer);
2773
3787
  process.off("SIGINT", stop);
2774
3788
  process.off("SIGTERM", stop);
2775
- synchronizer?.close();
2776
- await control?.close();
2777
- await sessions?.close();
2778
- 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();
2779
3799
  lock.close();
2780
3800
  }
2781
3801
  }
@@ -2789,13 +3809,15 @@ Usage:
2789
3809
  teammy --version Show the installed client version
2790
3810
  teammy server-info --url URL Check server connection and protocol capabilities
2791
3811
  teammy server-info --help Show connection options
2792
- 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]
2793
3813
  teammy status [--root DIR] [--state-dir DIR]
2794
- teammy sync [--root DIR] [--once]
3814
+ teammy sync on | off [--root DIR]
3815
+ teammy logs [--root DIR] Show recent diagnostic events
2795
3816
  teammy session --help Collect, read and search Agent sessions
2796
3817
 
2797
3818
  Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
2798
- 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.
2799
3821
  The hub server is installed separately as @teammy/server (command: teammy-server).
2800
3822
  `;
2801
3823
  var serverInfoHelp = `Usage: teammy server-info --url URL
@@ -2822,6 +3844,21 @@ async function runCli(args, version) {
2822
3844
  process.stderr.write('Install @teammy/server and run "teammy-server" to start the hub.\n');
2823
3845
  return 1;
2824
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
+ }
2825
3862
  if (command === "session") {
2826
3863
  try {
2827
3864
  return await runSessionCommand(args.slice(1));
@@ -2838,64 +3875,17 @@ async function runCli(args, version) {
2838
3875
  try {
2839
3876
  return await runSyncCommand(args.slice(1), version);
2840
3877
  } catch (error) {
2841
- process.stderr.write(
2842
- `${error instanceof Error ? error.message : "Synchronization failed."}
2843
- `
2844
- );
3878
+ process.stderr.write(`${diagnostic(error)}
3879
+ `);
2845
3880
  return 1;
2846
3881
  }
2847
3882
  }
2848
- if (command === "connect" || command === "status") {
3883
+ if (command === "connect" || command === "status" || command === "logs") {
2849
3884
  try {
2850
- const { values } = parseArgs3({
2851
- args: args.slice(1),
2852
- options: {
2853
- url: { type: "string" },
2854
- "token-file": { type: "string" },
2855
- member: { type: "string" },
2856
- reconnect: { type: "boolean" },
2857
- root: { type: "string", default: resolve6("workspaces") },
2858
- "state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") }
2859
- }
2860
- });
2861
- const rootPath = resolve6(values.root);
2862
- if (command === "connect") await mkdir7(rootPath, { recursive: true });
2863
- const root = await realpath6(rootPath);
2864
- const state = new ClientState(await stateLocation(root, values["state-dir"]));
2865
- try {
2866
- if (command === "status") {
2867
- const binding = state.binding(root);
2868
- if (!binding) throw new Error("This root is not connected. Run teammy connect first.");
2869
- const { token: _token, ...publicBinding } = binding;
2870
- process.stdout.write(
2871
- `${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)}
2872
- `
2873
- );
2874
- } else {
2875
- if (!values.url) throw new Error("--url is required.");
2876
- const connected = await connectProject(
2877
- state,
2878
- root,
2879
- await stateLocation(root, values["state-dir"]),
2880
- {
2881
- url: values.url,
2882
- member: values.member,
2883
- tokenFile: values["token-file"],
2884
- reconnect: values.reconnect
2885
- }
2886
- );
2887
- 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)}
2888
3888
  `);
2889
- }
2890
- } finally {
2891
- state.close();
2892
- }
2893
- return 0;
2894
- } catch (error) {
2895
- process.stderr.write(
2896
- `${error instanceof Error ? error.message : "Unable to open connection."}
2897
- `
2898
- );
2899
3889
  return 1;
2900
3890
  }
2901
3891
  }
@@ -2904,7 +3894,7 @@ async function runCli(args, version) {
2904
3894
  return 1;
2905
3895
  }
2906
3896
  try {
2907
- const { values } = parseArgs3({
3897
+ const { values } = parseArgs4({
2908
3898
  args: args.slice(1),
2909
3899
  options: {
2910
3900
  help: { type: "boolean", short: "h" },