@teammy/cli 0.0.10 → 0.1.1

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,6 +1,6 @@
1
1
  // apps/client/main.ts
2
- import { parseArgs as parseArgs4 } from "node:util";
3
- import { resolve as resolve9 } from "node:path";
2
+ import { parseArgs as parseArgs6 } from "node:util";
3
+ import { resolve as resolve15 } from "node:path";
4
4
 
5
5
  // packages/protocol/project.ts
6
6
  import { z as z3 } from "zod";
@@ -19,7 +19,9 @@ var MembersSchema = z.object({ members: z.array(NameSchema) });
19
19
 
20
20
  // packages/protocol/files.ts
21
21
  import { z as z2 } from "zod";
22
- var MAX_BLOB_BYTES = 10 * 1024 * 1024;
22
+ var MAX_BLOB_BYTES = 128 * 1024 * 1024;
23
+ var MAX_FILE_BYTES = 10 * 1024 * 1024;
24
+ var BLOB_TRANSFER_TIMEOUT_MS = 3e5;
23
25
  var MAX_BATCH_FILES = 200;
24
26
  var HashSchema = z2.string().regex(/^[a-f0-9]{64}$/);
25
27
  var RevisionSchema = z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
@@ -33,25 +35,29 @@ var BlobRefSchema = z2.object({
33
35
  sha256: HashSchema,
34
36
  size: z2.number().int().min(0).max(MAX_BLOB_BYTES)
35
37
  });
38
+ var FileBlobRefSchema = BlobRefSchema.extend({
39
+ size: z2.number().int().min(0).max(MAX_FILE_BYTES)
40
+ });
36
41
  var FileChangeSchema = z2.discriminatedUnion("kind", [
37
- BlobRefSchema.extend({ kind: z2.literal("upsert"), path: FilePathSchema }).strict(),
42
+ FileBlobRefSchema.extend({ kind: z2.literal("upsert"), path: FilePathSchema }).strict(),
38
43
  z2.object({ kind: z2.literal("delete"), path: FilePathSchema }).strict()
39
44
  ]);
40
45
  var FileCommitSchema = z2.object({
41
46
  operationId: z2.uuid(),
42
47
  parentRevision: RevisionSchema,
43
- changes: z2.array(FileChangeSchema).min(1).max(MAX_BATCH_FILES)
48
+ changes: z2.array(FileChangeSchema)
44
49
  }).strict().refine(
45
50
  (batch) => new Set(batch.changes.map((change) => change.path)).size === batch.changes.length,
46
- "Duplicate paths in a batch."
51
+ "Duplicate paths in a publication."
47
52
  );
48
53
  var CommitResultSchema = z2.object({ operationId: z2.uuid(), revision: RevisionSchema });
49
- var FileEntrySchema = BlobRefSchema.extend({
54
+ var FileEntrySchema = FileBlobRefSchema.extend({
50
55
  path: FilePathSchema,
51
56
  revision: RevisionSchema
52
57
  });
53
58
  var ManifestSchema = z2.object({
54
59
  revision: RevisionSchema,
60
+ complete: z2.boolean().default(false),
55
61
  files: z2.array(FileEntrySchema),
56
62
  nextCursor: z2.string().nullable()
57
63
  });
@@ -105,12 +111,8 @@ var ProjectConfigSchema = z3.object({
105
111
  }).strict();
106
112
  var SetupSchema = z3.object({
107
113
  operationId: z3.uuid(),
108
- projectName: z3.string().trim().min(1).max(120),
109
- description: z3.string().max(2e3).default(""),
110
114
  publicServerUrl: ServerUrlSchema,
111
- adminId: NameSchema,
112
- members: z3.array(NameSchema).max(100).default([]),
113
- sync: SyncPolicySchema.default(DEFAULT_POLICY)
115
+ adminId: NameSchema
114
116
  }).strict();
115
117
  var SetupStatusSchema = z3.object({
116
118
  initialized: z3.boolean()
@@ -148,7 +150,8 @@ var MemberUpdateSchema = z3.object({
148
150
  var ProjectCreateSchema = z3.object({
149
151
  operationId: z3.uuid(),
150
152
  projectName: z3.string().trim().min(1).max(120),
151
- description: z3.string().max(2e3).default("")
153
+ description: z3.string().max(2e3).default(""),
154
+ workspacePath: z3.string().trim().min(1).optional()
152
155
  }).strict();
153
156
  var ProjectSummarySchema = z3.object({
154
157
  projectId: NameSchema,
@@ -189,113 +192,136 @@ var UserUpdateSchema = z3.object({
189
192
  }).strict();
190
193
 
191
194
  // packages/protocol/sessions.ts
195
+ import { z as z5 } from "zod";
196
+
197
+ // packages/protocol/session-analysis.ts
192
198
  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),
199
+ var SessionAnalysisKindSchema = z4.enum(["overview", "handoff"]);
200
+ var SessionOverviewOutputSchema = z4.object({ title: z4.string().trim().min(1), summary: z4.string().trim().min(1) }).strict();
201
+ var SessionHandoffOutputSchema = z4.object({ markdown: z4.string().trim().min(1) }).strict();
202
+ var SessionOverviewSchema = SessionOverviewOutputSchema.extend({
203
+ sourceRevision: RevisionSchema,
204
+ sourceHash: HashSchema,
205
+ generatedAt: z4.string(),
206
+ stale: z4.boolean()
207
+ });
208
+ var SessionAnalysisStateSchema = z4.object({
209
+ kind: SessionAnalysisKindSchema,
210
+ status: z4.enum(["absent", "queued", "running", "succeeded", "failed"]),
211
+ available: z4.boolean(),
212
+ currentRevision: RevisionSchema,
213
+ sourceRevision: RevisionSchema.nullable(),
214
+ stale: z4.boolean(),
215
+ result: z4.union([SessionOverviewOutputSchema, SessionHandoffOutputSchema]).nullable(),
216
+ generatedAt: z4.string().nullable(),
217
+ error: z4.string().nullable()
218
+ });
219
+
220
+ // packages/protocol/sessions.ts
221
+ var AgentSchema = z5.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
222
+ var SessionSnapshotSchema = z5.object({
223
+ formatVersion: z5.literal(1),
224
+ adapterVersion: z5.literal(1),
197
225
  agent: AgentSchema,
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)
226
+ nativeId: z5.string().min(1).max(512),
227
+ cwd: z5.string().min(1).max(4096),
228
+ messages: z5.array(
229
+ z5.object({
230
+ id: z5.string().min(1).max(1024),
231
+ role: z5.enum(["user", "assistant"]),
232
+ text: z5.string().min(1),
233
+ source: z5.string().min(1).max(1024)
206
234
  }).strict()
207
- ).max(5e4)
235
+ )
208
236
  }).strict().refine(
209
237
  (s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
210
238
  "Duplicate message IDs."
211
239
  );
212
- var SessionCommitSchema = z4.object({
213
- operationId: z4.uuid(),
240
+ var SessionCommitSchema = z5.object({
241
+ operationId: z5.uuid(),
214
242
  sessionKey: HashSchema,
215
243
  parentRevision: RevisionSchema,
216
244
  blob: BlobRefSchema
217
245
  }).strict();
218
- var SessionInfoSchema = z4.object({
246
+ var SessionInfoSchema = z5.object({
219
247
  owner: NameSchema,
220
248
  sessionKey: HashSchema,
221
249
  agent: AgentSchema,
222
- nativeId: z4.string(),
223
- cwd: z4.string(),
224
- title: z4.string(),
250
+ nativeId: z5.string(),
251
+ cwd: z5.string(),
252
+ title: z5.string(),
225
253
  revision: RevisionSchema,
226
- updatedAt: z4.string(),
227
- messageCount: z4.number().int().nonnegative(),
228
- blob: BlobRefSchema
254
+ updatedAt: z5.string(),
255
+ messageCount: z5.number().int().nonnegative(),
256
+ blob: BlobRefSchema,
257
+ overview: SessionOverviewSchema.optional()
229
258
  });
230
- var SessionListSchema = z4.object({
231
- sessions: z4.array(SessionInfoSchema),
259
+ var SessionListSchema = z5.object({
260
+ sessions: z5.array(SessionInfoSchema),
232
261
  nextCursor: RevisionSchema.nullable()
233
262
  });
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()
263
+ var SessionStatsSchema = z5.object({
264
+ totalSessions: z5.number().int().nonnegative(),
265
+ totalMessages: z5.number().int().nonnegative(),
266
+ memberCount: z5.number().int().nonnegative(),
267
+ updatedAt: z5.string().nullable()
239
268
  });
240
- var SessionScopeSchema = z4.object({
269
+ var SessionScopeSchema = z5.object({
241
270
  owner: NameSchema.optional(),
242
271
  sessionKey: HashSchema.optional()
243
272
  }).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({
273
+ var SessionSearchSchema = z5.object({
274
+ matches: z5.array(
275
+ z5.object({
247
276
  owner: NameSchema,
248
277
  sessionKey: HashSchema,
249
278
  revision: RevisionSchema,
250
- messageId: z4.string(),
251
- role: z4.enum(["user", "assistant"]),
252
- snippet: z4.string(),
253
- title: z4.string()
279
+ messageId: z5.string(),
280
+ role: z5.enum(["user", "assistant"]),
281
+ snippet: z5.string(),
282
+ title: z5.string()
254
283
  })
255
284
  ),
256
285
  nextCursor: RevisionSchema.nullable()
257
286
  });
258
287
 
259
288
  // packages/transport/hub-client.ts
260
- import { z as z7 } from "zod";
289
+ import { z as z8 } from "zod";
261
290
  import { createHash } from "node:crypto";
262
291
 
263
292
  // packages/protocol/index.ts
264
- import { z as z5 } from "zod";
293
+ import { z as z6 } from "zod";
265
294
  var PROTOCOL_VERSION = 1;
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())
295
+ var HealthSchema = z6.object({ status: z6.literal("ok") });
296
+ var ServerInfoSchema = z6.object({
297
+ name: z6.literal("teammy"),
298
+ version: z6.string().min(1),
299
+ protocolVersion: z6.literal(PROTOCOL_VERSION),
300
+ capabilities: z6.array(z6.string())
272
301
  });
273
- var ErrorResponseSchema = z5.object({
274
- error: z5.object({
275
- code: z5.string(),
276
- message: z5.string(),
277
- requestId: z5.string()
302
+ var ErrorResponseSchema = z6.object({
303
+ error: z6.object({
304
+ code: z6.string(),
305
+ message: z6.string(),
306
+ requestId: z6.string()
278
307
  })
279
308
  });
280
309
 
281
310
  // packages/protocol/shared-files.ts
282
- import { z as z6 } from "zod";
311
+ import { z as z7 } from "zod";
283
312
  var PROJECT_FILE_OWNER = "_project";
284
- var FileOwnerSchema = z6.union([NameSchema, z6.literal(PROJECT_FILE_OWNER)]);
285
- var SharedPathSchema = z6.string().max(255).refine((path) => {
313
+ var FileOwnerSchema = z7.union([NameSchema, z7.literal(PROJECT_FILE_OWNER)]);
314
+ var SharedPathSchema = z7.string().max(255).refine((path) => {
286
315
  const directory = path.endsWith("/");
287
316
  const name2 = directory ? path.slice(0, -1) : path;
288
317
  return FilePathSchema.safeParse(name2).success && !name2.includes("/") && !name2.startsWith(".") && !/^_?teammy/i.test(name2) && (directory ? name2.startsWith("_") : name2.includes("."));
289
318
  }, "Use a root file (README.md) or an underscore-prefixed directory (_template/).");
290
- var SharedFilesConfigSchema = z6.object({
319
+ var SharedFilesConfigSchema = z7.object({
291
320
  maintainer: NameSchema.nullable(),
292
- paths: z6.array(SharedPathSchema).max(30).refine((paths) => new Set(paths.map((p) => p.toLowerCase())).size === paths.length),
321
+ paths: z7.array(SharedPathSchema).max(30).refine((paths) => new Set(paths.map((p) => p.toLowerCase())).size === paths.length),
293
322
  revision: RevisionSchema
294
323
  }).strict();
295
324
  var SharedFilesUpdateSchema = SharedFilesConfigSchema.omit({ revision: true }).extend({ expectedRevision: RevisionSchema }).strict();
296
- var SharedFileCommitSchema = FileCommitSchema.safeExtend({
297
- configRevision: RevisionSchema
298
- });
299
325
  function privateWorkspacePath(path) {
300
326
  return path.split("/").some((part) => /^\.teammy(?:[-.]|$)/i.test(part));
301
327
  }
@@ -359,15 +385,23 @@ var HubClient = class {
359
385
  }
360
386
  uploadBlob(hash2, content) {
361
387
  if (content.byteLength > MAX_BLOB_BYTES)
362
- throw new Error("Content exceeds the file size limit.");
363
- return this.request("PUT", `api/v1/blobs/${HashSchema.parse(hash2)}`, BlobRefSchema, content);
388
+ throw new Error("Content exceeds the binary object size limit.");
389
+ return this.request(
390
+ "PUT",
391
+ `api/v1/blobs/${HashSchema.parse(hash2)}`,
392
+ BlobRefSchema,
393
+ content,
394
+ BLOB_TRANSFER_TIMEOUT_MS
395
+ );
364
396
  }
365
397
  async downloadBlob(hash2) {
366
398
  const { response, signal } = await this.fetchResponse(
367
399
  "GET",
368
- `api/v1/blobs/${HashSchema.parse(hash2)}`
400
+ `api/v1/blobs/${HashSchema.parse(hash2)}`,
401
+ void 0,
402
+ BLOB_TRANSFER_TIMEOUT_MS
369
403
  );
370
- if (!response.ok) return this.decode(response, signal, z7.never());
404
+ if (!response.ok) return this.decode(response, signal, z8.never());
371
405
  const reader = response.body?.getReader();
372
406
  if (!reader) throw new Error("Server returned no content body.");
373
407
  const parts = [];
@@ -390,7 +424,7 @@ var HubClient = class {
390
424
  return bytes;
391
425
  }
392
426
  commitFiles(batch) {
393
- return this.request("POST", "api/v1/files/commits", CommitResultSchema, batch);
427
+ return this.publishFiles(batch);
394
428
  }
395
429
  fileManifest(owner, revision, after = "", limit = 200) {
396
430
  const query = new URLSearchParams({ after, limit: String(limit) });
@@ -412,7 +446,7 @@ var HubClient = class {
412
446
  return this.request(
413
447
  "PUT",
414
448
  "api/v1/devices/self/status",
415
- z7.object({ ok: z7.literal(true) }),
449
+ z8.object({ ok: z8.literal(true) }),
416
450
  status
417
451
  );
418
452
  }
@@ -426,17 +460,27 @@ var HubClient = class {
426
460
  return this.request("GET", "api/v1/project/shared-files", SharedFilesConfigSchema);
427
461
  }
428
462
  commitSharedFiles(batch, configRevision) {
429
- return this.request("POST", "api/v1/files/_project/commits", CommitResultSchema, {
430
- ...batch,
431
- configRevision
432
- });
463
+ return this.publishFiles({ ...batch, configRevision });
464
+ }
465
+ async publishFiles(publication) {
466
+ const bytes = Buffer.from(JSON.stringify(publication));
467
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
468
+ if ((await this.missingBlobs([sha256])).missing.length) await this.uploadBlob(sha256, bytes);
469
+ return this.request(
470
+ "POST",
471
+ "api/v1/files/publications",
472
+ CommitResultSchema,
473
+ { manifest: { sha256, size: bytes.byteLength } },
474
+ BLOB_TRANSFER_TIMEOUT_MS
475
+ );
433
476
  }
434
477
  commitSession(commit) {
435
478
  return this.request(
436
479
  "POST",
437
480
  "api/v1/sessions/commits",
438
481
  CommitResultSchema,
439
- SessionCommitSchema.parse(commit)
482
+ SessionCommitSchema.parse(commit),
483
+ BLOB_TRANSFER_TIMEOUT_MS
440
484
  );
441
485
  }
442
486
  sessions(after = 0, limit = 50) {
@@ -460,14 +504,14 @@ var HubClient = class {
460
504
  SessionSearchSchema
461
505
  );
462
506
  }
463
- async request(method, path, schema, requestBody) {
464
- const { response, signal } = await this.fetchResponse(method, path, requestBody);
507
+ async request(method, path, schema, requestBody, timeoutMs = this.timeoutMs) {
508
+ const { response, signal } = await this.fetchResponse(method, path, requestBody, timeoutMs);
465
509
  return this.decode(response, signal, schema);
466
510
  }
467
- async fetchResponse(method, path, requestBody) {
511
+ async fetchResponse(method, path, requestBody, timeoutMs = this.timeoutMs) {
468
512
  if (this.credentials?.workspaceId && path.startsWith("api/v1/") && !["api/v1/bind", "api/v1/server"].includes(path))
469
513
  path = `api/v1/projects/${encodeURIComponent(this.credentials.workspaceId)}/${path.slice("api/v1/".length)}`;
470
- const timeout = AbortSignal.timeout(this.timeoutMs);
514
+ const timeout = AbortSignal.timeout(timeoutMs);
471
515
  const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
472
516
  const binary = requestBody instanceof Uint8Array;
473
517
  const response = await fetch(new URL(path, this.baseUrl), {
@@ -480,7 +524,9 @@ var HubClient = class {
480
524
  "x-teammy-device": this.credentials.deviceId
481
525
  } : {}
482
526
  },
483
- ...requestBody === void 0 ? {} : { body: binary ? Uint8Array.from(requestBody).buffer : JSON.stringify(requestBody) },
527
+ ...requestBody === void 0 ? {} : {
528
+ body: binary ? requestBody : JSON.stringify(requestBody)
529
+ },
484
530
  signal,
485
531
  redirect: "error"
486
532
  });
@@ -518,11 +564,18 @@ var HubClient = class {
518
564
  };
519
565
 
520
566
  // apps/client/sessions/command.ts
521
- import { createHash as createHash7 } from "node:crypto";
522
- import { realpath as realpath4 } from "node:fs/promises";
523
- import { homedir as homedir2 } from "node:os";
524
- import { dirname as dirname5, join as join7, resolve as resolve4 } from "node:path";
525
- import { parseArgs } from "node:util";
567
+ import { createHash as createHash8 } from "node:crypto";
568
+ import { realpath as realpath6 } from "node:fs/promises";
569
+ import { homedir as homedir4 } from "node:os";
570
+ import { dirname as dirname9, join as join17, resolve as resolve8 } from "node:path";
571
+ import { parseArgs as parseArgs2 } from "node:util";
572
+
573
+ // packages/protocol/file-publication.ts
574
+ import { z as z9 } from "zod";
575
+ var FilePublicationSchema = FileCommitSchema.safeExtend({
576
+ configRevision: RevisionSchema.optional()
577
+ }).strict();
578
+ var PublishFilesSchema = z9.object({ manifest: BlobRefSchema }).strict();
526
579
 
527
580
  // packages/state/sqlite.ts
528
581
  import { chmodSync, mkdirSync } from "node:fs";
@@ -539,7 +592,7 @@ function transaction(db, action) {
539
592
  throw error;
540
593
  }
541
594
  }
542
- function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
595
+ function openDatabase(path, migrations, busyTimeoutMs = 5e3, journalMode = "WAL") {
543
596
  if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0)
544
597
  throw new Error("Invalid SQLite busy timeout.");
545
598
  mkdirSync(dirname(path), { recursive: true, mode: 448 });
@@ -547,7 +600,7 @@ function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
547
600
  try {
548
601
  chmodSync(path, 384);
549
602
  db.exec(
550
- `PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;`
603
+ `PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA foreign_keys = ON; PRAGMA journal_mode = ${journalMode}; PRAGMA synchronous = FULL;`
551
604
  );
552
605
  transaction(db, () => {
553
606
  const version = Number(db.prepare("PRAGMA user_version").get().user_version);
@@ -583,8 +636,14 @@ var FilePublicationState = class {
583
636
  }
584
637
  publication(root) {
585
638
  this.db.prepare(`INSERT OR IGNORE INTO ${this.tables.publications}(root) VALUES (?)`).run(root);
586
- const row = this.db.prepare(`SELECT revision, initialized FROM ${this.tables.publications} WHERE root=?`).get(root);
587
- return { revision: Number(row.revision), initialized: Boolean(row.initialized) };
639
+ const row = this.db.prepare(
640
+ `SELECT revision, initialized, complete FROM ${this.tables.publications} WHERE root=?`
641
+ ).get(root);
642
+ return {
643
+ revision: Number(row.revision),
644
+ initialized: Boolean(row.initialized),
645
+ complete: Boolean(row.complete)
646
+ };
588
647
  }
589
648
  initializePublication(root, revision, files, options = {}) {
590
649
  transaction(this.db, () => {
@@ -592,8 +651,8 @@ var FilePublicationState = class {
592
651
  if (this.pending(root))
593
652
  throw new Error("Cannot replace a baseline with a publication pending.");
594
653
  this.db.prepare(
595
- `INSERT INTO ${this.tables.publications} VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1`
596
- ).run(root, revision);
654
+ `INSERT INTO ${this.tables.publications}(root,revision,initialized,complete) VALUES (?, ?, 1, ?) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1,complete=excluded.complete`
655
+ ).run(root, revision, Number(options.complete ?? false));
597
656
  this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=?`).run(root);
598
657
  for (const file of files)
599
658
  this.db.prepare(`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?)`).run(root, file.path, file.sha256, file.size);
@@ -606,17 +665,18 @@ var FilePublicationState = class {
606
665
  }
607
666
  pending(root) {
608
667
  const row = this.db.prepare(
609
- `SELECT request_json, attempts, retry_at, last_error FROM ${this.tables.outbox} WHERE root=?`
668
+ `SELECT request_json, attempts, retry_at, last_error, next_index FROM ${this.tables.outbox} WHERE root=?`
610
669
  ).get(root);
611
670
  return row ? {
612
- batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
671
+ batch: FilePublicationSchema.parse(JSON.parse(String(row.request_json))),
672
+ nextIndex: Number(row.next_index),
613
673
  attempts: Number(row.attempts),
614
674
  retryAt: Number(row.retry_at),
615
675
  lastError: row.last_error === null ? null : String(row.last_error)
616
676
  } : void 0;
617
677
  }
618
678
  enqueue(root, batch) {
619
- const fixed = FileCommitSchema.parse(batch);
679
+ const fixed = FilePublicationSchema.parse(batch);
620
680
  transaction(this.db, () => {
621
681
  const publication = this.publication(root);
622
682
  if (!publication.initialized || publication.revision !== fixed.parentRevision)
@@ -625,6 +685,11 @@ var FilePublicationState = class {
625
685
  this.db.prepare(`INSERT INTO ${this.tables.outbox}(root, request_json) VALUES (?, ?)`).run(root, JSON.stringify(fixed));
626
686
  });
627
687
  }
688
+ uploaded(root, operationId, nextIndex) {
689
+ this.db.prepare(
690
+ `UPDATE ${this.tables.outbox} SET next_index=? WHERE root=? AND json_extract(request_json,'$.operationId')=?`
691
+ ).run(nextIndex, root, operationId);
692
+ }
628
693
  acknowledge(root, result2) {
629
694
  transaction(this.db, () => {
630
695
  const pending = this.pending(root);
@@ -638,7 +703,7 @@ var FilePublicationState = class {
638
703
  `INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size`
639
704
  ).run(root, change.path, change.sha256, change.size);
640
705
  }
641
- this.db.prepare(`UPDATE ${this.tables.publications} SET revision=? WHERE root=?`).run(result2.revision, root);
706
+ this.db.prepare(`UPDATE ${this.tables.publications} SET revision=?,complete=1 WHERE root=?`).run(result2.revision, root);
642
707
  this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
643
708
  });
644
709
  }
@@ -655,6 +720,19 @@ var FilePublicationState = class {
655
720
  }
656
721
  };
657
722
 
723
+ // apps/client/files/atomic-schema.ts
724
+ var atomicFilesClientMigration = ["", "project_"].map(
725
+ (prefix) => `
726
+ ALTER TABLE ${prefix}publications ADD COLUMN complete INTEGER NOT NULL DEFAULT 0;
727
+ ALTER TABLE ${prefix}outbox ADD COLUMN next_index INTEGER NOT NULL DEFAULT 0;
728
+ -- Old outboxes contain a partial batch, not a complete publication. Reconcile
729
+ -- against the server (including a possibly lost acknowledgement) before rebuilding.
730
+ DELETE FROM ${prefix}outbox;
731
+ UPDATE ${prefix}publications SET initialized=0;
732
+ UPDATE ${prefix}file_index SET dirty=1,event_pending=1,generation=generation+1;
733
+ `
734
+ ).join("\n");
735
+
658
736
  // apps/client/files/state.ts
659
737
  var syncMigration = String.raw`
660
738
  CREATE TABLE file_index (
@@ -872,33 +950,191 @@ var RuntimeState = class {
872
950
  };
873
951
 
874
952
  // apps/client/state.ts
875
- import { randomUUID as randomUUID3 } from "node:crypto";
876
- import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
877
- import { z as z9 } from "zod";
953
+ import { randomUUID as randomUUID4 } from "node:crypto";
954
+ import { join as join7, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
955
+ import { z as z12 } from "zod";
956
+ import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
957
+
958
+ // apps/client/runtime/state-access.ts
959
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
960
+ import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync } from "node:fs";
961
+ import { join as join2 } from "node:path";
962
+
963
+ // apps/client/runtime/metadata.ts
964
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, writeFileSync } from "node:fs";
965
+ import { dirname as dirname2, join } from "node:path";
966
+ import { randomUUID } from "node:crypto";
967
+ function writePrivateJson(path, value) {
968
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
969
+ const temp = path + `.${randomUUID()}.tmp`;
970
+ writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { mode: 384 });
971
+ renameSync(temp, path);
972
+ }
973
+ function maintenancePath(directory) {
974
+ return join(directory, "runtime", "maintenance.json");
975
+ }
976
+ function readMaintenance(directory) {
977
+ const path = maintenancePath(directory);
978
+ if (!existsSync(path)) return null;
979
+ const value = JSON.parse(readFileSync(path, "utf8"));
980
+ if (value.format !== 1 || !["repair", "upgrade", "paused", "uninstalled"].includes(value.reason))
981
+ throw new Error("Unsupported maintenance record. Inspect teammy logs --all.");
982
+ return value;
983
+ }
984
+ function writeMaintenance(directory, record) {
985
+ writePrivateJson(maintenancePath(directory), {
986
+ ...record,
987
+ format: 1,
988
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
989
+ });
990
+ }
991
+
992
+ // apps/client/runtime/state-access.ts
993
+ function stateAccess(directory, exclusive = false) {
994
+ const path = join2(directory, "runtime", "access.sqlite3");
995
+ mkdirSync3(join2(directory, "runtime"), { recursive: true, mode: 448 });
996
+ const db = new DatabaseSync2(path);
997
+ try {
998
+ chmodSync2(path, 384);
999
+ db.exec("PRAGMA busy_timeout=0");
1000
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='access'").get()) {
1001
+ db.exec(
1002
+ "PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS access(id INTEGER PRIMARY KEY); INSERT OR IGNORE INTO access VALUES(1)"
1003
+ );
1004
+ }
1005
+ db.exec(exclusive ? "BEGIN EXCLUSIVE" : "BEGIN");
1006
+ db.prepare("SELECT id FROM access").get();
1007
+ if (!exclusive) {
1008
+ const record = readMaintenance(directory);
1009
+ if (record && ["repair", "upgrade"].includes(record.reason))
1010
+ throw new Error("Client maintenance is pending. Run teammy repair.");
1011
+ }
1012
+ return db;
1013
+ } catch (error) {
1014
+ db.close();
1015
+ if (error instanceof Error && /locked|busy/.test(error.message))
1016
+ throw new Error(
1017
+ "Client state is in use. Finish the local foreground command, then run teammy repair."
1018
+ );
1019
+ throw error;
1020
+ }
1021
+ }
1022
+ function legacyStateLocks(directory) {
1023
+ const locks = [];
1024
+ try {
1025
+ const path = join2(directory, "locks");
1026
+ if (existsSync2(path))
1027
+ for (const file of readdirSync(path)) {
1028
+ if (!/^[a-f0-9]{64}\.sqlite3$/.test(file)) continue;
1029
+ const db = new DatabaseSync2(join2(path, file));
1030
+ locks.push(db);
1031
+ db.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
1032
+ }
1033
+ return { close: () => locks.forEach((db) => db.close()) };
1034
+ } catch {
1035
+ locks.forEach((db) => db.close());
1036
+ throw new Error(
1037
+ "An older sync process is still using this client. Stop its foreground command before repair."
1038
+ );
1039
+ }
1040
+ }
1041
+ function stateVersion(directory) {
1042
+ const path = join2(directory, "client.sqlite3");
1043
+ if (!existsSync2(path)) return 0;
1044
+ const db = new DatabaseSync2(path, { readOnly: true });
1045
+ try {
1046
+ return Number(db.prepare("PRAGMA user_version").get().user_version);
1047
+ } finally {
1048
+ db.close();
1049
+ }
1050
+ }
878
1051
 
879
1052
  // apps/client/workspace.ts
880
1053
  import { execFile } from "node:child_process";
881
1054
  import { promisify } from "node:util";
882
1055
  import { lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rmdir as rmdir2 } from "node:fs/promises";
883
- import { basename, dirname as dirname3, join as join2, resolve } from "node:path";
1056
+ import { basename, dirname as dirname4, join as join5, resolve } from "node:path";
884
1057
 
885
1058
  // apps/client/files/local-fs.ts
886
- import { createHash as createHash2, randomUUID } from "node:crypto";
887
- import { constants } from "node:fs";
888
- import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
889
- import { dirname as dirname2, join, relative, sep } from "node:path";
1059
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
1060
+ import { constants as constants2 } from "node:fs";
1061
+ import { lstat, mkdir, open as open2, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
1062
+ import { dirname as dirname3, join as join4, relative, sep } from "node:path";
890
1063
  import { setImmediate } from "node:timers/promises";
1064
+
1065
+ // packages/workspace/local-config.ts
1066
+ import { constants } from "node:fs";
1067
+ import { open, writeFile } from "node:fs/promises";
1068
+ import { join as join3 } from "node:path";
1069
+ import { z as z10 } from "zod";
1070
+ import ignore from "ignore";
1071
+ function ignored(path) {
1072
+ return path.split("/").some(
1073
+ (part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:[-.]|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
1074
+ );
1075
+ }
891
1076
  function missing(error) {
892
1077
  return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
893
1078
  }
1079
+ var LOCAL_CONFIG_NAME = ".teammy-local.json";
1080
+ var LocalConfigSchema = z10.object({
1081
+ files: z10.object({ ignore: z10.array(z10.string().max(1024)).max(256).default([]) }).strict().default({ ignore: [] })
1082
+ }).strict();
1083
+ async function ensureLocalConfig(root) {
1084
+ try {
1085
+ await writeFile(
1086
+ join3(root, LOCAL_CONFIG_NAME),
1087
+ JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
1088
+ { flag: "wx", mode: 384 }
1089
+ );
1090
+ } catch (error) {
1091
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
1092
+ }
1093
+ }
1094
+ async function loadLocalRules(root) {
1095
+ let rules = [];
1096
+ try {
1097
+ const file = await open(
1098
+ join3(root, LOCAL_CONFIG_NAME),
1099
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
1100
+ );
1101
+ try {
1102
+ const before = await file.stat();
1103
+ if (!before.isFile() || before.size > 65536)
1104
+ throw new Error("Expected a regular configuration file no larger than 64 KiB.");
1105
+ const buffer = Buffer.alloc(65537);
1106
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
1107
+ if (bytesRead > 65536 || bytesRead !== before.size)
1108
+ throw new Error("Configuration exceeds the read limit or changed while reading.");
1109
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
1110
+ const after = await file.stat();
1111
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
1112
+ throw new Error("Configuration is being saved; retry on the next check.");
1113
+ rules = LocalConfigSchema.parse(JSON.parse(text)).files.ignore;
1114
+ } finally {
1115
+ await file.close();
1116
+ }
1117
+ } catch (error) {
1118
+ if (!missing(error))
1119
+ throw new Error(
1120
+ `${LOCAL_CONFIG_NAME}: invalid or unreadable configuration; uploads paused.`,
1121
+ { cause: error }
1122
+ );
1123
+ }
1124
+ const matcher = ignore().add(rules);
1125
+ return {
1126
+ fingerprint: JSON.stringify(rules),
1127
+ excludes: (path, directory = false) => ignored(path) || matcher.ignores(path + (directory && !path.endsWith("/") ? "/" : ""))
1128
+ };
1129
+ }
1130
+
1131
+ // apps/client/files/local-fs.ts
1132
+ function missing2(error) {
1133
+ return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
1134
+ }
894
1135
  function signature(stat2) {
895
1136
  return [stat2.dev, stat2.ino, stat2.size, stat2.mtimeNs, stat2.ctimeNs].join(":");
896
1137
  }
897
- function ignored(path) {
898
- return path.split("/").some(
899
- (part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:[-.]|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
900
- );
901
- }
902
1138
  async function safePath(root, path, createParents = false) {
903
1139
  FilePathSchema.parse(path);
904
1140
  const rootStat = await lstat(root);
@@ -907,7 +1143,7 @@ async function safePath(root, path, createParents = false) {
907
1143
  const parts = path.split("/");
908
1144
  let parent = root;
909
1145
  for (const part of parts.slice(0, -1)) {
910
- parent = join(parent, part);
1146
+ parent = join4(parent, part);
911
1147
  if (createParents)
912
1148
  await mkdir(parent).catch((error) => {
913
1149
  if (error.code !== "EEXIST") throw error;
@@ -917,7 +1153,7 @@ async function safePath(root, path, createParents = false) {
917
1153
  if (!stat2.isDirectory())
918
1154
  throw Object.assign(new Error(`Parent is not a directory: ${path}`), { code: "ENOTDIR" });
919
1155
  }
920
- return join(root, ...parts);
1156
+ return join4(root, ...parts);
921
1157
  }
922
1158
  async function metadata(root, path) {
923
1159
  try {
@@ -927,7 +1163,7 @@ async function metadata(root, path) {
927
1163
  throw new Error(`Unsupported filesystem entry: ${path}`);
928
1164
  return { signature: signature(stat2), directory: stat2.isDirectory() };
929
1165
  } catch (error) {
930
- if (missing(error)) return null;
1166
+ if (missing2(error)) return null;
931
1167
  throw error;
932
1168
  }
933
1169
  }
@@ -977,12 +1213,12 @@ async function readStable(root, path) {
977
1213
  const inside = relative(await realpath(root), resolvedBefore);
978
1214
  if (inside.startsWith(`..${sep}`) || inside === "..")
979
1215
  throw new Error(`File escapes sync root: ${path}`);
980
- const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1216
+ const file = await open2(target, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
981
1217
  try {
982
1218
  const before = await file.stat({ bigint: true });
983
- if (!before.isFile() || before.size > BigInt(MAX_BLOB_BYTES))
1219
+ if (!before.isFile() || before.size > BigInt(MAX_FILE_BYTES))
984
1220
  throw new Error(
985
- `Only regular files up to ${MAX_BLOB_BYTES} bytes can be synchronized: ${path}`
1221
+ `Only regular files up to ${MAX_FILE_BYTES} bytes can be synchronized: ${path}`
986
1222
  );
987
1223
  const buffer = Buffer.alloc(Number(before.size) + 1);
988
1224
  let count = 0;
@@ -1007,7 +1243,7 @@ async function readStable(root, path) {
1007
1243
  }
1008
1244
  async function syncDirectory(directory) {
1009
1245
  if (process.platform === "win32") return;
1010
- const handle = await open(directory, "r");
1246
+ const handle = await open2(directory, "r");
1011
1247
  try {
1012
1248
  await handle.sync();
1013
1249
  } finally {
@@ -1016,8 +1252,8 @@ async function syncDirectory(directory) {
1016
1252
  }
1017
1253
  async function replaceFile(root, path, bytes, expected) {
1018
1254
  const target = await safePath(root, path, true);
1019
- const temporary = join(dirname2(target), `.teammy-${randomUUID()}.tmp`);
1020
- const file = await open(temporary, "wx", 384);
1255
+ const temporary = join4(dirname3(target), `.teammy-${randomUUID2()}.tmp`);
1256
+ const file = await open2(temporary, "wx", 384);
1021
1257
  try {
1022
1258
  try {
1023
1259
  await file.writeFile(bytes);
@@ -1033,15 +1269,15 @@ async function replaceFile(root, path, bytes, expected) {
1033
1269
  throw new Error(`Local file changed before replacement: ${path}`);
1034
1270
  await safePath(root, path);
1035
1271
  await rename(temporary, target);
1036
- let directory = dirname2(target);
1272
+ let directory = dirname3(target);
1037
1273
  for (; ; ) {
1038
1274
  await syncDirectory(directory);
1039
- if (directory === dirname2(root)) break;
1040
- directory = dirname2(directory);
1275
+ if (directory === dirname3(root)) break;
1276
+ directory = dirname3(directory);
1041
1277
  }
1042
1278
  } finally {
1043
1279
  await unlink(temporary).catch((error) => {
1044
- if (!missing(error)) throw error;
1280
+ if (!missing2(error)) throw error;
1045
1281
  });
1046
1282
  }
1047
1283
  }
@@ -1052,16 +1288,16 @@ async function removeFile(root, path, expected) {
1052
1288
  throw new Error(`Local file changed before deletion: ${path}`);
1053
1289
  const target = await safePath(root, path);
1054
1290
  await unlink(target);
1055
- await syncDirectory(dirname2(target));
1056
- let parent = dirname2(target);
1291
+ await syncDirectory(dirname3(target));
1292
+ let parent = dirname3(target);
1057
1293
  while (parent !== root) {
1058
1294
  try {
1059
1295
  await rmdir(parent);
1060
- await syncDirectory(dirname2(parent));
1296
+ await syncDirectory(dirname3(parent));
1061
1297
  } catch {
1062
1298
  break;
1063
1299
  }
1064
- parent = dirname2(parent);
1300
+ parent = dirname3(parent);
1065
1301
  }
1066
1302
  }
1067
1303
 
@@ -1069,7 +1305,7 @@ async function removeFile(root, path, expected) {
1069
1305
  var exec = promisify(execFile);
1070
1306
  async function workspacePath(input) {
1071
1307
  const path = resolve(input);
1072
- return join2(await realpath2(dirname3(path)), basename(path));
1308
+ return join5(await realpath2(dirname4(path)), basename(path));
1073
1309
  }
1074
1310
  async function directoryIdentity(root) {
1075
1311
  const stat2 = await lstat2(root, { bigint: true });
@@ -1086,18 +1322,18 @@ async function verifyWorkspace(state, root) {
1086
1322
  );
1087
1323
  }
1088
1324
  async function ignorePlan(root) {
1089
- const parent = dirname3(root), name2 = basename(root);
1325
+ const parent = dirname4(root), name2 = basename(root);
1090
1326
  if (/[\r\n]/.test(name2)) throw new Error("Workspace name cannot contain a newline.");
1091
1327
  let repo = false;
1092
- for (let p = parent; ; p = dirname3(p)) {
1328
+ for (let p = parent; ; p = dirname4(p)) {
1093
1329
  try {
1094
- await lstat2(join2(p, ".git"));
1330
+ await lstat2(join5(p, ".git"));
1095
1331
  repo = true;
1096
1332
  break;
1097
1333
  } catch (e) {
1098
- if (!missing(e)) throw e;
1334
+ if (!missing2(e)) throw e;
1099
1335
  }
1100
- if (p === dirname3(p)) break;
1336
+ if (p === dirname4(p)) break;
1101
1337
  }
1102
1338
  if (repo) {
1103
1339
  let tracked;
@@ -1142,7 +1378,7 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
1142
1378
  await lstat2(root);
1143
1379
  present = true;
1144
1380
  } catch (e) {
1145
- if (!missing(e)) throw e;
1381
+ if (!missing2(e)) throw e;
1146
1382
  }
1147
1383
  if (present) {
1148
1384
  if (reconnect && state.binding(root)) {
@@ -1179,8 +1415,8 @@ function completePreparation(state, root) {
1179
1415
  }
1180
1416
 
1181
1417
  // apps/client/runtime/log.ts
1182
- import { appendFileSync, mkdirSync as mkdirSync2, renameSync, statSync, unlinkSync, readFileSync } from "node:fs";
1183
- import { join as join3 } from "node:path";
1418
+ import { appendFileSync, mkdirSync as mkdirSync4, renameSync as renameSync2, statSync, unlinkSync, readFileSync as readFileSync2 } from "node:fs";
1419
+ import { join as join6 } from "node:path";
1184
1420
  import { createHash as createHash3 } from "node:crypto";
1185
1421
  function rootKey(root) {
1186
1422
  return createHash3("sha256").update(root).digest("hex").slice(0, 16);
@@ -1196,9 +1432,9 @@ function diagnostic(error) {
1196
1432
  var ClientLog = class {
1197
1433
  constructor(directory, limit = 1024 * 1024) {
1198
1434
  this.limit = limit;
1199
- const logs = join3(directory, "logs");
1200
- mkdirSync2(logs, { recursive: true, mode: 448 });
1201
- this.path = join3(logs, "client.jsonl");
1435
+ const logs = join6(directory, "logs");
1436
+ mkdirSync4(logs, { recursive: true, mode: 448 });
1437
+ this.path = join6(logs, "client.jsonl");
1202
1438
  }
1203
1439
  limit;
1204
1440
  path;
@@ -1215,10 +1451,10 @@ var ClientLog = class {
1215
1451
  } catch {
1216
1452
  }
1217
1453
  try {
1218
- renameSync(this.path + ".1", this.path + ".2");
1454
+ renameSync2(this.path + ".1", this.path + ".2");
1219
1455
  } catch {
1220
1456
  }
1221
- renameSync(this.path, this.path + ".1");
1457
+ renameSync2(this.path, this.path + ".1");
1222
1458
  }
1223
1459
  appendFileSync(
1224
1460
  this.path,
@@ -1237,7 +1473,7 @@ var ClientLog = class {
1237
1473
  const lines = [];
1238
1474
  for (const suffix of [".2", ".1", ""])
1239
1475
  try {
1240
- for (const line of readFileSync(this.path + suffix, "utf8").split("\n")) {
1476
+ for (const line of readFileSync2(this.path + suffix, "utf8").split("\n")) {
1241
1477
  if (!line) continue;
1242
1478
  try {
1243
1479
  const item = JSON.parse(line);
@@ -1255,50 +1491,50 @@ var ClientLog = class {
1255
1491
  import { setTimeout as setTimeout2 } from "node:timers/promises";
1256
1492
 
1257
1493
  // packages/protocol/devices.ts
1258
- import { z as z8 } from "zod";
1259
- var CredentialSchema = z8.string().regex(/^tmy_[a-f0-9]{64}$/);
1260
- var EnrollmentSchema = z8.object({
1261
- operationId: z8.uuid(),
1494
+ import { z as z11 } from "zod";
1495
+ var CredentialSchema = z11.string().regex(/^tmy_[a-f0-9]{64}$/);
1496
+ var EnrollmentSchema = z11.object({
1497
+ operationId: z11.uuid(),
1262
1498
  projectId: NameSchema,
1263
1499
  memberId: NameSchema,
1264
1500
  deviceId: DeviceIdSchema,
1265
1501
  credential: CredentialSchema
1266
1502
  }).strict();
1267
- var EffectivePolicySchema = z8.object({
1268
- checkIntervalSeconds: z8.number().min(1).max(86400),
1269
- fileSettleSeconds: z8.number().min(0).max(86400)
1503
+ var EffectivePolicySchema = z11.object({
1504
+ checkIntervalSeconds: z11.number().min(1).max(86400),
1505
+ fileSettleSeconds: z11.number().min(0).max(86400)
1270
1506
  }).strict();
1271
- var SyncSummarySchema = z8.object({
1272
- checkedAt: z8.string(),
1273
- published: z8.boolean(),
1274
- appliedFiles: z8.number().int().nonnegative(),
1275
- pendingFiles: z8.number().int().nonnegative(),
1276
- sessionSources: z8.number().int().nonnegative(),
1277
- errors: z8.array(z8.string().max(1e3)).max(20)
1507
+ var SyncSummarySchema = z11.object({
1508
+ checkedAt: z11.string(),
1509
+ published: z11.boolean(),
1510
+ appliedFiles: z11.number().int().nonnegative(),
1511
+ pendingFiles: z11.number().int().nonnegative(),
1512
+ sessionSources: z11.number().int().nonnegative(),
1513
+ errors: z11.array(z11.string().max(1e3)).max(20)
1278
1514
  }).strict();
1279
- var DeviceStatusSchema = z8.object({
1280
- clientVersion: z8.string().max(64),
1515
+ var DeviceStatusSchema = z11.object({
1516
+ clientVersion: z11.string().max(64),
1281
1517
  receivedRevision: RevisionSchema.nullable(),
1282
1518
  appliedRevision: RevisionSchema.nullable(),
1283
1519
  effectivePolicy: EffectivePolicySchema,
1284
- overrides: z8.array(z8.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
1285
- configError: z8.string().max(2e3).nullable(),
1286
- reportError: z8.string().max(2e3).nullable(),
1520
+ overrides: z11.array(z11.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
1521
+ configError: z11.string().max(2e3).nullable(),
1522
+ reportError: z11.string().max(2e3).nullable(),
1287
1523
  lastSync: SyncSummarySchema.nullable()
1288
1524
  }).strict();
1289
- var WebDevicesSchema = z8.object({
1290
- devices: z8.array(
1291
- z8.object({
1525
+ var WebDevicesSchema = z11.object({
1526
+ devices: z11.array(
1527
+ z11.object({
1292
1528
  memberId: NameSchema,
1293
- bound: z8.boolean(),
1294
- lastSeenAt: z8.string().nullable(),
1529
+ bound: z11.boolean(),
1530
+ lastSeenAt: z11.string().nullable(),
1295
1531
  status: DeviceStatusSchema.nullable()
1296
1532
  })
1297
1533
  )
1298
1534
  });
1299
1535
 
1300
1536
  // apps/client/project-state.ts
1301
- import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
1537
+ import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
1302
1538
  var projectClientMigration = String.raw`
1303
1539
  CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
1304
1540
  CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
@@ -1322,7 +1558,7 @@ var ClientProjectState = class {
1322
1558
  return request2;
1323
1559
  }
1324
1560
  const request = {
1325
- operationId: randomUUID2(),
1561
+ operationId: randomUUID3(),
1326
1562
  memberId,
1327
1563
  projectId,
1328
1564
  deviceId,
@@ -1495,6 +1731,20 @@ function readControlStatus(state, root) {
1495
1731
  return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
1496
1732
  }
1497
1733
 
1734
+ // apps/client/sessions/versions.ts
1735
+ var parserVersions = {
1736
+ "claude-code": 1,
1737
+ codex: 1,
1738
+ cursor: 1,
1739
+ pi: 2,
1740
+ dsh: 1
1741
+ };
1742
+ var sessionParserMigration = `
1743
+ ALTER TABLE session_sources ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0;
1744
+ ALTER TABLE session_publications ADD COLUMN pending_parser_version INTEGER NOT NULL DEFAULT 0;
1745
+ ALTER TABLE session_discovery_cache ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0;
1746
+ `;
1747
+
1498
1748
  // apps/client/sessions/state.ts
1499
1749
  var sessionClientMigration = String.raw`
1500
1750
  CREATE TABLE session_sources (
@@ -1554,12 +1804,12 @@ var SessionState = class {
1554
1804
  get(path) {
1555
1805
  return this.sources(path)[0];
1556
1806
  }
1557
- sources(path, limit = 2147483647) {
1807
+ sources(path, limit = 2147483647, before = Number.MAX_SAFE_INTEGER) {
1558
1808
  return this.state.db.prepare(
1559
- `SELECT s.*, p.revision,p.hash,p.selected_path,p.pending_json,p.pending_signature,p.pending_path,p.attempts,p.retry_at
1809
+ `SELECT s.*, p.revision,p.hash,p.selected_path,p.pending_json,p.pending_signature,p.pending_path,p.pending_parser_version,p.attempts,p.retry_at
1560
1810
  FROM session_sources s LEFT JOIN session_publications p USING(root,session_key)
1561
- WHERE s.root=? ${path === void 0 ? "" : "AND s.path=?"} ORDER BY s.checked_at, s.path LIMIT ?`
1562
- ).all(this.root, ...path === void 0 ? [] : [path], limit).map((row) => ({
1811
+ WHERE s.root=? AND s.checked_at<? ${path === void 0 ? "" : "AND s.path=?"} ORDER BY s.checked_at, s.path LIMIT ?`
1812
+ ).all(this.root, before, ...path === void 0 ? [] : [path], limit).map((row) => ({
1563
1813
  path: String(row.path),
1564
1814
  agent: row.agent,
1565
1815
  projectDir: String(row.project_dir),
@@ -1569,6 +1819,8 @@ var SessionState = class {
1569
1819
  signature: row.signature,
1570
1820
  checkedAt: Number(row.checked_at),
1571
1821
  verifiedAt: Number(row.verified_at),
1822
+ parserVersion: Number(row.parser_version),
1823
+ pendingParserVersion: Number(row.pending_parser_version ?? 0),
1572
1824
  lastError: row.last_error,
1573
1825
  adoptExisting: Boolean(row.adopt_existing),
1574
1826
  pending: row.pending_json ? SessionCommitSchema.parse(JSON.parse(String(row.pending_json))) : null,
@@ -1607,21 +1859,28 @@ var SessionState = class {
1607
1859
  }
1608
1860
  stage(path, request, signature2) {
1609
1861
  this.state.db.prepare(
1610
- `UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,attempts=0,retry_at=0
1862
+ `UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,pending_parser_version=?,attempts=0,retry_at=0
1611
1863
  WHERE root=? AND session_key=? AND pending_json IS NULL`
1612
- ).run(JSON.stringify(request), signature2, path, this.root, request.sessionKey);
1864
+ ).run(
1865
+ JSON.stringify(request),
1866
+ signature2,
1867
+ path,
1868
+ parserVersions[this.get(path).agent],
1869
+ this.root,
1870
+ request.sessionKey
1871
+ );
1613
1872
  }
1614
- acknowledge(path, revision, hash2, signature2) {
1873
+ acknowledge(path, revision, hash2, signature2, parserVersion) {
1615
1874
  const source = this.get(path);
1616
1875
  this.state.db.prepare(
1617
1876
  `UPDATE session_publications SET revision=?,hash=?,selected_path=?,pending_json=NULL,pending_signature=NULL,pending_path=NULL,attempts=0,retry_at=0 WHERE root=? AND session_key=?`
1618
1877
  ).run(revision, hash2, path, this.root, source.key);
1619
- this.observed(path, signature2);
1878
+ this.observed(path, signature2, parserVersion);
1620
1879
  }
1621
- observed(path, signature2) {
1880
+ observed(path, signature2, parserVersion = parserVersions[this.get(path).agent]) {
1622
1881
  this.state.db.prepare(
1623
- "UPDATE session_sources SET signature=?,verified_at=?,last_error=NULL,adopt_existing=0 WHERE root=? AND path=?"
1624
- ).run(signature2, Date.now(), this.root, path);
1882
+ "UPDATE session_sources SET signature=?,verified_at=?,parser_version=?,last_error=NULL,adopt_existing=0 WHERE root=? AND path=?"
1883
+ ).run(signature2, Date.now(), parserVersion, this.root, path);
1625
1884
  }
1626
1885
  baseline(path, key, revision) {
1627
1886
  this.state.db.prepare(
@@ -1643,10 +1902,12 @@ var SessionState = class {
1643
1902
  this.checked(source.path, error);
1644
1903
  }
1645
1904
  discover(agent, directory, projectDir) {
1905
+ const prior = this.directories().find((item) => item.directory === directory);
1906
+ if (prior?.agent === agent && prior.projectDir === projectDir) return;
1646
1907
  this.state.db.prepare(
1647
1908
  "INSERT INTO session_discovery VALUES (?,?,?,?) ON CONFLICT(root,directory) DO UPDATE SET agent=excluded.agent,project_dir=excluded.project_dir"
1648
1909
  ).run(this.root, directory, agent, projectDir);
1649
- this.state.db.prepare("DELETE FROM session_discovery_cache WHERE root=?").run(this.root);
1910
+ this.state.db.prepare("DELETE FROM session_discovery_cache WHERE root=? AND substr(path,1,?)=?").run(this.root, directory.length + 1, directory + "/");
1650
1911
  }
1651
1912
  receivedHook(agent) {
1652
1913
  this.state.db.prepare(
@@ -1667,33 +1928,75 @@ var SessionState = class {
1667
1928
 
1668
1929
  // apps/client/state.ts
1669
1930
  var LocalBindingSchema = BindingSchema.extend({
1670
- root: z9.string(),
1671
- serverUrl: z9.string(),
1672
- token: z9.string()
1931
+ root: z12.string(),
1932
+ serverUrl: z12.string(),
1933
+ token: z12.string()
1673
1934
  });
1674
- var ClientState = class extends FilePublicationState {
1675
- constructor(directory) {
1676
- const db = openDatabase(join4(directory, "client.sqlite3"), [
1677
- String.raw`
1935
+ var clientMigrations = [
1936
+ String.raw`
1678
1937
  CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
1679
1938
  `,
1680
- String.raw`
1939
+ String.raw`
1681
1940
  CREATE TABLE publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
1682
1941
  CREATE TABLE owner_files (root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, path));
1683
1942
  CREATE TABLE outbox (root TEXT PRIMARY KEY REFERENCES connections(root), request_json TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, last_error TEXT);
1684
1943
  `,
1685
- syncMigration,
1686
- sessionClientMigration,
1687
- projectClientMigration,
1688
- controlClientMigration,
1689
- runtimeMigration,
1690
- projectFilesClientMigration,
1691
- sessionSourcesMigration
1692
- ]);
1944
+ syncMigration,
1945
+ sessionClientMigration,
1946
+ projectClientMigration,
1947
+ controlClientMigration,
1948
+ runtimeMigration,
1949
+ projectFilesClientMigration,
1950
+ sessionSourcesMigration,
1951
+ atomicFilesClientMigration,
1952
+ sessionParserMigration
1953
+ ];
1954
+ var clientSchemaVersion = clientMigrations.length;
1955
+ var ClientState = class extends FilePublicationState {
1956
+ access;
1957
+ constructor(directory, options = {}) {
1958
+ let access2;
1959
+ let db;
1960
+ try {
1961
+ if (!options.maintenance) access2 = stateAccess(directory);
1962
+ const version = stateVersion(directory);
1963
+ if (version > clientSchemaVersion)
1964
+ throw new Error(
1965
+ "Database was created by a newer Teammy version. Local stop and logs remain available."
1966
+ );
1967
+ if (version > 0 && version < clientSchemaVersion)
1968
+ throw new Error("Client state needs an upgrade. Run teammy repair.");
1969
+ if (options.readOnly) {
1970
+ if (!version) throw new Error("No client state is available.");
1971
+ db = new DatabaseSync3(join7(directory, "client.sqlite3"), { readOnly: true });
1972
+ } else if (!version) {
1973
+ access2?.close();
1974
+ access2 = void 0;
1975
+ const exclusive = options.maintenance ? void 0 : stateAccess(directory, true);
1976
+ try {
1977
+ db = openDatabase(join7(directory, "client.sqlite3"), clientMigrations);
1978
+ } finally {
1979
+ exclusive?.close();
1980
+ }
1981
+ if (!options.maintenance) access2 = stateAccess(directory);
1982
+ } else {
1983
+ db = new DatabaseSync3(join7(directory, "client.sqlite3"));
1984
+ db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL;");
1985
+ }
1986
+ } catch (error) {
1987
+ db?.close();
1988
+ access2?.close();
1989
+ throw error;
1990
+ }
1693
1991
  super(db);
1992
+ this.access = access2;
1694
1993
  }
1695
1994
  close() {
1696
- this.db.close();
1995
+ try {
1996
+ this.db.close();
1997
+ } finally {
1998
+ this.access?.close();
1999
+ }
1697
2000
  }
1698
2001
  assertRootAvailable(root) {
1699
2002
  for (const row of this.db.prepare("SELECT root FROM connections").all()) {
@@ -1709,7 +2012,7 @@ var ClientState = class extends FilePublicationState {
1709
2012
  }
1710
2013
  deviceFor(root) {
1711
2014
  this.assertRootAvailable(root);
1712
- this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
2015
+ this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID4());
1713
2016
  return String(
1714
2017
  this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
1715
2018
  );
@@ -1754,11 +2057,11 @@ async function stateLocation(root, directory) {
1754
2057
  // apps/client/sessions/setup.ts
1755
2058
  import { execFile as execFile2 } from "node:child_process";
1756
2059
  import { promisify as promisify2 } from "node:util";
1757
- import { access, mkdir as mkdir4, readFile, writeFile } from "node:fs/promises";
1758
- import { constants as constants2 } from "node:fs";
2060
+ import { access, mkdir as mkdir5, readFile as readFile2, writeFile as writeFile2, realpath as realpath4 } from "node:fs/promises";
2061
+ import { constants as constants3 } from "node:fs";
1759
2062
  import { homedir } from "node:os";
1760
- import { dirname as dirname4, join as join5, resolve as resolve3 } from "node:path";
1761
- import { createHash as createHash5 } from "node:crypto";
2063
+ import { dirname as dirname6, join as join11, resolve as resolve4 } from "node:path";
2064
+ import { createHash as createHash6 } from "node:crypto";
1762
2065
 
1763
2066
  // apps/client/sessions/normalize.ts
1764
2067
  function object(value) {
@@ -1792,6 +2095,9 @@ var JsonlNormalizer = class {
1792
2095
  nativeId;
1793
2096
  cwd;
1794
2097
  turn = "legacy";
2098
+ piParents = /* @__PURE__ */ new Map();
2099
+ piLeaf = null;
2100
+ piTree = false;
1795
2101
  candidates = [];
1796
2102
  add(role, content, id, line, visible = false) {
1797
2103
  if (role !== "user" && role !== "assistant") return;
@@ -1839,8 +2145,11 @@ var JsonlNormalizer = class {
1839
2145
  this.add(payload.role, payload.content, payload.id, line);
1840
2146
  }
1841
2147
  } else if (this.agent === "cursor") {
1842
- const message2 = object(row.message);
1843
2148
  if (row.isSidechain === true) return;
2149
+ const sessionId = row.sessionId ?? row.session_id;
2150
+ if (typeof sessionId === "string" && this.nativeId && sessionId !== this.nativeId)
2151
+ throw new Error("Mixed Cursor session identities.");
2152
+ const message2 = object(row.message);
1844
2153
  this.add(row.role ?? message2.role, message2.content ?? row.content, row.id ?? row.uuid, line);
1845
2154
  } else if (this.agent === "pi") {
1846
2155
  if (row.type === "session") {
@@ -1849,6 +2158,14 @@ var JsonlNormalizer = class {
1849
2158
  this.nativeId = row.id;
1850
2159
  this.cwd = row.cwd;
1851
2160
  }
2161
+ if (row.type !== "session" && typeof row.id === "string") {
2162
+ this.piTree ||= Object.hasOwn(row, "parentId");
2163
+ this.piParents.set(
2164
+ row.id,
2165
+ typeof row.parentId === "string" ? row.parentId : row.parentId === null ? null : this.piLeaf
2166
+ );
2167
+ this.piLeaf = row.id;
2168
+ }
1852
2169
  if (row.type === "message") {
1853
2170
  const message2 = object(row.message);
1854
2171
  this.add(message2.role, message2.content, row.id, line);
@@ -1876,9 +2193,25 @@ var JsonlNormalizer = class {
1876
2193
  }
1877
2194
  }
1878
2195
  finish() {
2196
+ let piBranch;
2197
+ if (this.agent === "pi" && this.piTree) {
2198
+ piBranch = /* @__PURE__ */ new Set();
2199
+ let id = this.piLeaf;
2200
+ while (id && !piBranch.has(id)) {
2201
+ piBranch.add(id);
2202
+ id = this.piParents.get(id) ?? null;
2203
+ }
2204
+ }
1879
2205
  const visibleGroups = new Set(this.candidates.filter((m) => m.visible).map((m) => m.group));
1880
2206
  const ids = /* @__PURE__ */ new Map();
1881
- const messages = this.candidates.filter((m) => m.visible || !visibleGroups.has(m.group)).map((m) => {
2207
+ const seen = /* @__PURE__ */ new Set();
2208
+ const messages = this.candidates.filter((m) => !piBranch || typeof m.id === "string" && piBranch.has(m.id)).filter((m) => m.visible || !visibleGroups.has(m.group)).filter((m) => {
2209
+ if (typeof m.id !== "string" || !m.id) return true;
2210
+ const identity = JSON.stringify([m.id, m.role, m.text]);
2211
+ if (seen.has(identity)) return false;
2212
+ seen.add(identity);
2213
+ return true;
2214
+ }).map((m) => {
1882
2215
  const base = typeof m.id === "string" && m.id ? m.id : `line:${m.line}`;
1883
2216
  const count = (ids.get(base) ?? 0) + 1;
1884
2217
  ids.set(base, count);
@@ -1895,10 +2228,167 @@ var JsonlNormalizer = class {
1895
2228
  }
1896
2229
  };
1897
2230
 
2231
+ // apps/client/sessions/registration.ts
2232
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
2233
+ import { join as join8 } from "node:path";
2234
+ var registrationPath = (directory, root) => join8(directory, "hook-registrations", rootKey(root) + ".json");
2235
+ function readRegistration(directory, root) {
2236
+ const path = registrationPath(directory, root);
2237
+ if (!existsSync3(path)) return null;
2238
+ const value = JSON.parse(readFileSync3(path, "utf8"));
2239
+ if (value.format !== 1 || value.root !== root || !Array.isArray(value.configs))
2240
+ throw new Error("Invalid Hook registration.");
2241
+ return value;
2242
+ }
2243
+ function saveRegistration(directory, value) {
2244
+ writePrivateJson(registrationPath(directory, value.root), value);
2245
+ }
2246
+ function registrations(directory, onError) {
2247
+ const path = join8(directory, "hook-registrations");
2248
+ if (!existsSync3(path)) return [];
2249
+ return readdirSync2(path).filter((name2) => /^[a-f0-9]{16}\.json$/.test(name2)).flatMap((name2) => {
2250
+ try {
2251
+ const value = JSON.parse(readFileSync3(join8(path, name2), "utf8"));
2252
+ const registration = readRegistration(directory, value.root);
2253
+ if (!registration) throw new Error("Missing Hook registration.");
2254
+ return [registration];
2255
+ } catch (error) {
2256
+ onError(join8(path, name2), error);
2257
+ return [];
2258
+ }
2259
+ });
2260
+ }
2261
+
2262
+ // apps/client/root-lock.ts
2263
+ import { createHash as createHash5 } from "node:crypto";
2264
+ import { join as join9 } from "node:path";
2265
+ function acquireRootLock(root, stateDirectory) {
2266
+ let lock;
2267
+ try {
2268
+ lock = openDatabase(
2269
+ join9(stateDirectory, "locks", `${createHash5("sha256").update(root).digest("hex")}.sqlite3`),
2270
+ [],
2271
+ 0
2272
+ );
2273
+ lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
2274
+ return lock;
2275
+ } catch (error) {
2276
+ lock?.close();
2277
+ if (error instanceof Error && /locked|busy/.test(error.message))
2278
+ throw new Error("Another sync or connect process is using this root.");
2279
+ throw error;
2280
+ }
2281
+ }
2282
+
2283
+ // apps/client/sessions/setup.ts
2284
+ import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
2285
+
2286
+ // apps/client/sessions/pi-extension.ts
2287
+ import { mkdir as mkdir4, readFile } from "node:fs/promises";
2288
+ import { dirname as dirname5, basename as basename2, join as join10, resolve as resolve3 } from "node:path";
2289
+ var marker = (directory) => `// Teammy Pi collection ${rootKey(directory)}`;
2290
+ function piPath(path, home, cwd) {
2291
+ return resolve3(
2292
+ cwd,
2293
+ path === "~" ? home : path.startsWith("~/") ? join10(home, path.slice(2)) : path
2294
+ );
2295
+ }
2296
+ async function piDiscoveryDirectories(agentDirectory, project, home, env) {
2297
+ let configured;
2298
+ for (const path of [join10(agentDirectory, "settings.json"), join10(project, ".pi/settings.json")]) {
2299
+ try {
2300
+ const settings = JSON.parse(await readFile(path, "utf8"));
2301
+ if (typeof settings?.sessionDir === "string" && settings.sessionDir)
2302
+ configured = settings.sessionDir;
2303
+ } catch (error) {
2304
+ if (!missing2(error)) throw new Error(`Unable to read Pi session directory setting: ${path}`);
2305
+ }
2306
+ }
2307
+ configured = env.PI_CODING_AGENT_SESSION_DIR || configured;
2308
+ return [
2309
+ .../* @__PURE__ */ new Set([
2310
+ join10(agentDirectory, "sessions"),
2311
+ ...configured ? [piPath(configured, home, project)] : []
2312
+ ])
2313
+ ];
2314
+ }
2315
+ function piExtensionSource(directory, path, entry) {
2316
+ return `${marker(directory)}
2317
+ import { spawn } from 'node:child_process';
2318
+
2319
+ export default function teammyCollection(pi) {
2320
+ const collect = async (_event, ctx) => {
2321
+ try {
2322
+ const manager = ctx.sessionManager;
2323
+ const file = manager.getSessionFile();
2324
+ if (!file || manager.isPersisted?.() === false) return;
2325
+ const payload = JSON.stringify({
2326
+ cwd: manager.getCwd?.() ?? ctx.cwd,
2327
+ session_id: manager.getSessionId(),
2328
+ transcript_path: file,
2329
+ });
2330
+ const child = spawn(${JSON.stringify(process.execPath)}, [
2331
+ ${JSON.stringify(join10(dirname5(entry), "session-hook.js"))},
2332
+ '--agent', 'pi', '--state-dir', ${JSON.stringify(directory)},
2333
+ '--integration-file', ${JSON.stringify(path)},
2334
+ ], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, shell: false });
2335
+ await new Promise((resolve) => {
2336
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(); }, 2000);
2337
+ const finish = () => { clearTimeout(timer); resolve(); };
2338
+ child.once('error', finish);
2339
+ child.once('close', finish);
2340
+ child.stdin.on('error', () => {});
2341
+ child.stdin.end(payload);
2342
+ });
2343
+ } catch { /* Collection never affects the host session. */ }
2344
+ };
2345
+ // agent_end is supported by older Pi releases too. It follows message
2346
+ // persistence; retries or queued turns simply produce another hint later.
2347
+ for (const event of ['session_start', 'agent_end', 'session_shutdown']) {
2348
+ try { pi.on(event, collect); } catch {}
2349
+ }
2350
+ }
2351
+ `;
2352
+ }
2353
+ async function installPiExtension(directory, registry, agentDirectory, entry) {
2354
+ const path = join10(agentDirectory, "extensions", `teammy-${rootKey(directory)}.js`);
2355
+ await mkdir4(dirname5(path), { recursive: true });
2356
+ let original = null;
2357
+ try {
2358
+ original = await readStable(dirname5(path), basename2(path));
2359
+ } catch (error) {
2360
+ if (!missing2(error)) throw error;
2361
+ }
2362
+ if (original && !original.bytes.toString("utf8").startsWith(marker(directory) + "\n"))
2363
+ throw new Error(`Pi extension is not owned by Teammy: ${path}`);
2364
+ if (!registry.configs.some((config) => config.path === path && config.kind === "pi-extension"))
2365
+ registry.configs.push({ path, agent: "pi", kind: "pi-extension", created: !original });
2366
+ saveRegistration(directory, registry);
2367
+ const bytes = Buffer.from(piExtensionSource(directory, path, entry));
2368
+ if (!original?.bytes.equals(bytes))
2369
+ await replaceFile(dirname5(path), basename2(path), bytes, original?.signature ?? null);
2370
+ }
2371
+ async function removePiExtension(directory, path) {
2372
+ const issues = [];
2373
+ const remaining = registrations(directory, (file) => issues.push(file));
2374
+ if (issues.length || remaining.some((r) => r.enabled && r.configs.some((c) => c.path === path)))
2375
+ return false;
2376
+ try {
2377
+ const original = await readStable(dirname5(path), basename2(path));
2378
+ if (!original.bytes.toString("utf8").startsWith(marker(directory) + "\n"))
2379
+ throw new Error(`Pi extension is not owned by Teammy: ${path}`);
2380
+ await removeFile(dirname5(path), basename2(path), original.signature);
2381
+ return true;
2382
+ } catch (error) {
2383
+ if (missing2(error)) return false;
2384
+ throw error;
2385
+ }
2386
+ }
2387
+
1898
2388
  // apps/client/sessions/setup.ts
1899
2389
  var exec2 = promisify2(execFile2);
1900
2390
  var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
1901
- async function exists(path, mode = constants2.F_OK) {
2391
+ async function exists(path, mode = constants3.F_OK) {
1902
2392
  try {
1903
2393
  await access(path, mode);
1904
2394
  return true;
@@ -1907,176 +2397,399 @@ async function exists(path, mode = constants2.F_OK) {
1907
2397
  }
1908
2398
  }
1909
2399
  async function setupSessions(state, root, directory, options = {}) {
1910
- const home = options.home ?? homedir(), env = options.env ?? process.env;
1911
- const project = dirname4(root), sessions = new SessionState(state, root);
1912
- const defaults = [
1913
- {
1914
- agent: "claude-code",
1915
- executables: ["claude"],
1916
- storage: env.CLAUDE_CONFIG_DIR ?? join5(home, ".claude"),
1917
- directories: ["projects"],
1918
- config: ".claude/settings.local.json",
1919
- events: ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"]
1920
- },
1921
- {
1922
- agent: "codex",
1923
- executables: ["codex"],
1924
- storage: env.CODEX_HOME ?? join5(home, ".codex"),
1925
- directories: ["sessions"],
1926
- config: ".codex/hooks.json",
1927
- events: ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"]
1928
- },
1929
- {
1930
- agent: "cursor",
1931
- executables: ["agent", "cursor-agent"],
1932
- storage: env.CURSOR_CONFIG_DIR ?? join5(home, ".cursor"),
1933
- directories: ["chats", "projects"],
1934
- config: ".cursor/hooks.json",
1935
- events: ["sessionStart", "postToolUse", "stop", "sessionEnd"]
1936
- },
1937
- {
1938
- agent: "pi",
1939
- executables: ["pi"],
1940
- storage: env.PI_CODING_AGENT_DIR ?? join5(home, ".pi", "agent"),
1941
- directories: ["sessions"]
1942
- }
1943
- ];
1944
- const issues = [], configured = [];
1945
- let gitRoot = project;
2400
+ await realpath4(root);
2401
+ if (!state.binding(root)) throw new Error("Connect this root before installing hooks.");
2402
+ const lock = acquireRootLock("hooks", join11(directory, "runtime"));
1946
2403
  try {
1947
- gitRoot = (await exec2("git", ["-C", project, "rev-parse", "--show-toplevel"])).stdout.trim();
1948
- } catch {
1949
- }
1950
- for (const adapter of defaults) {
1951
- const installed = options.agents ? options.agents.includes(adapter.agent) : await exists(adapter.storage) || (await Promise.all(
1952
- (env.PATH ?? "").split(":").flatMap(
1953
- (dir) => adapter.executables.map((name2) => exists(join5(dir, name2), constants2.X_OK))
1954
- )
1955
- )).some(Boolean);
1956
- if (!installed) continue;
1957
- for (const sub of adapter.directories)
1958
- sessions.discover(adapter.agent, resolve3(adapter.storage, sub), project);
1959
- if (adapter.agent === "cursor")
1960
- sessions.discover("cursor", join5(home, ".cursor", "projects"), project);
1961
- configured.push(adapter.agent);
1962
- if (!adapter.config) continue;
1963
- const configRoot = adapter.agent === "cursor" ? gitRoot : project;
1964
- const path = join5(configRoot, adapter.config);
1965
- try {
1966
- await mkdir4(dirname4(path), { recursive: true });
1967
- let original = null;
1968
- try {
1969
- original = await readStable(configRoot, adapter.config);
1970
- } catch (error) {
1971
- if (!missing(error)) throw error;
1972
- }
1973
- const config = original ? JSON.parse(original.bytes.toString("utf8")) : {};
1974
- if (!config || typeof config !== "object" || Array.isArray(config))
1975
- throw new Error("Hook configuration must be a JSON object.");
1976
- if (config.hooks !== void 0 && (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)))
1977
- throw new Error("Invalid existing hooks object.");
1978
- const hooks = object(config.hooks);
1979
- const marker = `teammy-session-${rootKey(root)}`;
1980
- const command = [
1981
- process.execPath,
1982
- options.entry ?? resolve3(process.argv[1]),
1983
- "session",
1984
- "hook",
1985
- "--agent",
1986
- adapter.agent,
1987
- "--root",
1988
- root,
1989
- "--state-dir",
1990
- directory,
1991
- "--project-dir",
1992
- project
1993
- ].map(quote).join(" ") + ` # ${marker}`;
1994
- for (const event of adapter.events) {
1995
- const entries = hooks[event] ?? [];
1996
- if (!Array.isArray(entries)) throw new Error(`Invalid ${event} hooks.`);
1997
- const retained = entries.flatMap((entry) => {
1998
- const value = object(entry);
1999
- if (adapter.agent === "cursor")
2000
- return typeof value.command === "string" && value.command.includes(marker) ? [] : [entry];
2001
- if (!Array.isArray(value.hooks)) return [entry];
2002
- const nested = value.hooks.filter(
2003
- (hook) => !(typeof object(hook).command === "string" && String(object(hook).command).includes(marker))
2004
- );
2005
- return nested.length ? [{ ...value, hooks: nested }] : [];
2006
- });
2007
- hooks[event] = [
2008
- ...retained,
2009
- adapter.agent === "cursor" ? { command, timeout: 3 } : { hooks: [{ type: "command", command, timeout: 3 }] }
2010
- ];
2404
+ const prior = readRegistration(directory, root);
2405
+ if (prior && !prior.enabled && !options.enable)
2406
+ return { configured: [], issues: [] };
2407
+ const registry = prior ?? {
2408
+ format: 1,
2409
+ root,
2410
+ projectDir: dirname6(root),
2411
+ enabled: true,
2412
+ configs: []
2413
+ };
2414
+ registry.enabled = true;
2415
+ saveRegistration(directory, registry);
2416
+ const home = options.home ?? homedir(), env = options.env ?? process.env;
2417
+ const project = dirname6(root), sessions = new SessionState(state, root);
2418
+ const defaults = [
2419
+ {
2420
+ agent: "claude-code",
2421
+ executables: ["claude"],
2422
+ storage: env.CLAUDE_CONFIG_DIR ?? join11(home, ".claude"),
2423
+ directories: ["projects"],
2424
+ config: ".claude/settings.local.json",
2425
+ events: ["Stop", "SessionEnd"]
2426
+ },
2427
+ {
2428
+ agent: "codex",
2429
+ executables: ["codex"],
2430
+ storage: env.CODEX_HOME ?? join11(home, ".codex"),
2431
+ directories: ["sessions"],
2432
+ config: ".codex/hooks.json",
2433
+ events: ["Stop", "SessionEnd"]
2434
+ },
2435
+ {
2436
+ agent: "claude-code",
2437
+ instance: "tclaude",
2438
+ executables: ["tclaude"],
2439
+ storage: join11(home, ".tclaude"),
2440
+ directories: ["projects"],
2441
+ config: ".claude/settings.local.json",
2442
+ events: ["Stop", "SessionEnd"]
2443
+ },
2444
+ {
2445
+ agent: "codex",
2446
+ instance: "tcodex",
2447
+ executables: ["tcodex"],
2448
+ storage: join11(home, ".tcodex"),
2449
+ directories: ["sessions"],
2450
+ config: ".codex/hooks.json",
2451
+ events: ["Stop", "SessionEnd"]
2452
+ },
2453
+ {
2454
+ agent: "cursor",
2455
+ executables: ["agent", "cursor-agent"],
2456
+ storage: env.CURSOR_CONFIG_DIR ?? join11(home, ".cursor"),
2457
+ directories: ["chats", "projects"],
2458
+ config: ".cursor/hooks.json",
2459
+ events: ["sessionStart", "postToolUse", "stop", "sessionEnd"]
2460
+ },
2461
+ {
2462
+ agent: "pi",
2463
+ executables: ["pi"],
2464
+ storage: piPath(env.PI_CODING_AGENT_DIR ?? join11(home, ".pi", "agent"), home, project),
2465
+ directories: []
2011
2466
  }
2012
- config.hooks = hooks;
2013
- if (adapter.agent === "cursor") config.version ??= 1;
2014
- const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
2015
- if (original?.bytes.equals(bytes)) continue;
2016
- if (original) {
2017
- const backupDir = join5(directory, "hook-backups", rootKey(root));
2018
- await mkdir4(backupDir, { recursive: true, mode: 448 });
2019
- const hash2 = createHash5("sha256").update(original.bytes).digest("hex");
2020
- await writeFile(join5(backupDir, `${adapter.agent}-${hash2}.json`), original.bytes, {
2021
- mode: 384
2022
- });
2467
+ ];
2468
+ const issues = [], configured = [];
2469
+ let gitRoot = project;
2470
+ try {
2471
+ gitRoot = (await exec2("git", ["-C", project, "rev-parse", "--show-toplevel"])).stdout.trim();
2472
+ } catch {
2473
+ }
2474
+ const written = /* @__PURE__ */ new Set();
2475
+ for (const adapter of defaults) {
2476
+ const installed = options.agents ? options.agents.includes(adapter.instance ?? adapter.agent) : await exists(adapter.storage) || (await Promise.all(
2477
+ (env.PATH ?? "").split(":").flatMap(
2478
+ (dir) => adapter.executables.map((name2) => exists(join11(dir, name2), constants3.X_OK))
2479
+ )
2480
+ )).some(Boolean);
2481
+ const piProfiles = adapter.agent === "pi" ? registry.configs.filter((config) => config.kind === "pi-extension").map((config) => dirname6(dirname6(config.path))) : [];
2482
+ if (!installed && !piProfiles.length) continue;
2483
+ for (const sub of adapter.directories) {
2484
+ const source = resolve4(adapter.storage, sub);
2485
+ if (!registry.excludedDiscovery?.includes(source))
2486
+ sessions.discover(adapter.agent, source, project);
2487
+ }
2488
+ if (adapter.agent === "cursor" && !registry.excludedDiscovery?.includes(join11(home, ".cursor", "projects")))
2489
+ sessions.discover("cursor", join11(home, ".cursor", "projects"), project);
2490
+ if (!configured.includes(adapter.agent)) configured.push(adapter.agent);
2491
+ if (adapter.agent === "pi") {
2492
+ for (const profile of /* @__PURE__ */ new Set([...piProfiles, ...installed ? [adapter.storage] : []])) {
2493
+ try {
2494
+ if (profile !== adapter.storage && !await exists(profile))
2495
+ throw new Error(`Registered Pi profile is missing: ${profile}`);
2496
+ await installPiExtension(
2497
+ directory,
2498
+ registry,
2499
+ profile,
2500
+ options.entry ?? resolve4(process.argv[1])
2501
+ );
2502
+ for (const source of await piDiscoveryDirectories(profile, project, home, env)) {
2503
+ if (!registry.excludedDiscovery?.includes(source))
2504
+ sessions.discover("pi", source, project);
2505
+ }
2506
+ } catch (error) {
2507
+ issues.push(
2508
+ `Pi: ${error instanceof Error ? error.message : "Extension setup failed."}`
2509
+ );
2510
+ }
2511
+ }
2023
2512
  }
2024
- await replaceFile(configRoot, adapter.config, bytes, original?.signature ?? null);
2513
+ if (!adapter.config) continue;
2514
+ const configRoot = adapter.agent === "cursor" ? gitRoot : project;
2515
+ const path = join11(configRoot, adapter.config);
2516
+ if (written.has(path)) continue;
2517
+ written.add(path);
2025
2518
  try {
2026
- const relativePath = path.slice(gitRoot.length + 1);
2027
- const tracked = (await exec2("git", ["-C", gitRoot, "ls-files", "--", relativePath])).stdout;
2028
- if (!tracked) {
2029
- const exclude = (await exec2("git", [
2030
- "-C",
2031
- gitRoot,
2032
- "rev-parse",
2033
- "--path-format=absolute",
2034
- "--git-path",
2035
- "info/exclude"
2036
- ])).stdout.trim();
2037
- const old = await readFile(exclude, "utf8").catch((e) => {
2038
- if (missing(e)) return "";
2039
- throw e;
2519
+ await mkdir5(dirname6(path), { recursive: true });
2520
+ let original = null;
2521
+ try {
2522
+ original = await readStable(configRoot, adapter.config);
2523
+ } catch (error) {
2524
+ if (!missing2(error)) throw error;
2525
+ }
2526
+ const config = original ? JSON.parse(original.bytes.toString("utf8")) : {};
2527
+ if (!config || typeof config !== "object" || Array.isArray(config))
2528
+ throw new Error("Hook configuration must be a JSON object.");
2529
+ if (config.hooks !== void 0 && (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)))
2530
+ throw new Error("Invalid existing hooks object.");
2531
+ const hooks = object(config.hooks);
2532
+ const marker2 = hookMarker(directory, root);
2533
+ const cliEntry = options.entry ?? resolve4(process.argv[1]);
2534
+ const hookEntry = join11(dirname6(cliEntry), "session-hook.js");
2535
+ const invocation = [
2536
+ process.execPath,
2537
+ hookEntry,
2538
+ "--agent",
2539
+ adapter.agent,
2540
+ "--root",
2541
+ root,
2542
+ "--state-dir",
2543
+ directory,
2544
+ "--project-dir",
2545
+ project
2546
+ ].map(quote).join(" ");
2547
+ const command = `( ${invocation} ) >/dev/null 2>&1 || : # ${marker2}`;
2548
+ removeOwned(hooks, adapter.agent, directory, root);
2549
+ for (const event of adapter.events) {
2550
+ const entries = hooks[event] ?? [];
2551
+ if (!Array.isArray(entries)) throw new Error(`Invalid ${event} hooks.`);
2552
+ hooks[event] = [
2553
+ ...entries,
2554
+ adapter.agent === "cursor" ? { command, timeout: 3 } : { hooks: [{ type: "command", command, timeout: 3 }] }
2555
+ ];
2556
+ }
2557
+ let owned = registry.configs.find((item) => item.path === path);
2558
+ if (!owned) {
2559
+ owned = { path, agent: adapter.agent, created: !original };
2560
+ registry.configs.push(owned);
2561
+ }
2562
+ saveRegistration(directory, registry);
2563
+ config.hooks = hooks;
2564
+ if (adapter.agent === "cursor") config.version ??= 1;
2565
+ const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
2566
+ if (original?.bytes.equals(bytes)) continue;
2567
+ if (original) {
2568
+ const backupDir = join11(directory, "hook-backups", rootKey(root));
2569
+ await mkdir5(backupDir, { recursive: true, mode: 448 });
2570
+ const hash2 = createHash6("sha256").update(original.bytes).digest("hex");
2571
+ await writeFile2(join11(backupDir, `${adapter.agent}-${hash2}.json`), original.bytes, {
2572
+ mode: 384
2040
2573
  });
2041
- const rule = "/" + relativePath.replace(/[\\*?[\]#! ]/g, "\\$&");
2042
- if (!old.split(/\r?\n/).includes(rule)) {
2043
- await mkdir4(dirname4(exclude), { recursive: true });
2044
- await writeFile(exclude, old + (old && !old.endsWith("\n") ? "\n" : "") + rule + "\n");
2574
+ }
2575
+ await replaceFile(configRoot, adapter.config, bytes, original?.signature ?? null);
2576
+ try {
2577
+ const relativePath = path.slice(gitRoot.length + 1);
2578
+ const tracked = (await exec2("git", ["-C", gitRoot, "ls-files", "--", relativePath])).stdout;
2579
+ if (!tracked) {
2580
+ const exclude = (await exec2("git", [
2581
+ "-C",
2582
+ gitRoot,
2583
+ "rev-parse",
2584
+ "--path-format=absolute",
2585
+ "--git-path",
2586
+ "info/exclude"
2587
+ ])).stdout.trim();
2588
+ const old = await readFile2(exclude, "utf8").catch((e) => {
2589
+ if (missing2(e)) return "";
2590
+ throw e;
2591
+ });
2592
+ const rule = "/" + relativePath.replace(/[\\*?[\]#! ]/g, "\\$&");
2593
+ if (!old.split(/\r?\n/).includes(rule)) {
2594
+ await mkdir5(dirname6(exclude), { recursive: true });
2595
+ owned.exclude = {
2596
+ path: exclude,
2597
+ line: "# teammy-hook-" + rootKey(directory) + "-" + rootKey(root) + "\n" + rule
2598
+ };
2599
+ saveRegistration(directory, registry);
2600
+ await writeFile2(
2601
+ exclude,
2602
+ old + (old && !old.endsWith("\n") ? "\n" : "") + owned.exclude.line + "\n"
2603
+ );
2604
+ }
2045
2605
  }
2606
+ } catch {
2046
2607
  }
2047
- } catch {
2608
+ } catch (error) {
2609
+ issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
2048
2610
  }
2049
- } catch (error) {
2050
- issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
2051
2611
  }
2612
+ return { configured, issues };
2613
+ } finally {
2614
+ lock.close();
2615
+ }
2616
+ }
2617
+ function hookMarker(directory, root) {
2618
+ return `teammy-session-${rootKey(directory)}-${rootKey(root)}`;
2619
+ }
2620
+ function setDiscoveryPreference(directory, root, source, enabled) {
2621
+ const lock = acquireRootLock("hooks", join11(directory, "runtime"));
2622
+ try {
2623
+ const registry = readRegistration(directory, root);
2624
+ if (!registry) return;
2625
+ const excluded = new Set(registry.excludedDiscovery ?? []);
2626
+ if (enabled) excluded.delete(source);
2627
+ else excluded.add(source);
2628
+ registry.excludedDiscovery = [...excluded];
2629
+ saveRegistration(directory, registry);
2630
+ } finally {
2631
+ lock.close();
2632
+ }
2633
+ }
2634
+ function owns(command, directory, root) {
2635
+ if (typeof command !== "string") return false;
2636
+ if (command.trimEnd().endsWith("# " + hookMarker(directory, root))) return true;
2637
+ return command.trimEnd().endsWith("# teammy-session-" + rootKey(root)) && command.includes(quote("--state-dir") + " " + quote(directory));
2638
+ }
2639
+ function removeOwned(hooks, agent, directory, root) {
2640
+ let changed = false;
2641
+ for (const [event, entries] of Object.entries(hooks)) {
2642
+ if (!Array.isArray(entries)) continue;
2643
+ const retained = entries.flatMap((entry) => {
2644
+ const value = object(entry);
2645
+ if (agent === "cursor") return owns(value.command, directory, root) ? [] : [entry];
2646
+ if (!Array.isArray(value.hooks)) return [entry];
2647
+ const nested = value.hooks.filter((hook) => !owns(object(hook).command, directory, root));
2648
+ if (nested.length === value.hooks.length) return [entry];
2649
+ return nested.length ? [{ ...value, hooks: nested }] : [];
2650
+ });
2651
+ if (JSON.stringify(retained) === JSON.stringify(entries)) continue;
2652
+ changed = true;
2653
+ if (retained.length) hooks[event] = retained;
2654
+ else delete hooks[event];
2655
+ }
2656
+ return changed;
2657
+ }
2658
+ async function legacyRegistrations(directory, explicitRoot) {
2659
+ const roots = new Set(explicitRoot ? [explicitRoot] : []);
2660
+ let db;
2661
+ try {
2662
+ db = new DatabaseSync4(join11(directory, "client.sqlite3"), { readOnly: true });
2663
+ for (const row of db.prepare("SELECT root FROM connections WHERE binding_json IS NOT NULL").all())
2664
+ if (!explicitRoot || row.root === explicitRoot) roots.add(String(row.root));
2665
+ } catch {
2666
+ } finally {
2667
+ db?.close();
2668
+ }
2669
+ for (const root of roots) {
2670
+ try {
2671
+ if (readRegistration(directory, root)) continue;
2672
+ } catch {
2673
+ continue;
2674
+ }
2675
+ const project = dirname6(root);
2676
+ let gitRoot = project;
2677
+ try {
2678
+ gitRoot = (await exec2("git", ["-C", project, "rev-parse", "--show-toplevel"])).stdout.trim();
2679
+ } catch {
2680
+ }
2681
+ const configs = [
2682
+ { path: join11(project, ".claude/settings.local.json"), agent: "claude-code", created: false },
2683
+ { path: join11(project, ".codex/hooks.json"), agent: "codex", created: false },
2684
+ { path: join11(gitRoot, ".cursor/hooks.json"), agent: "cursor", created: false }
2685
+ ];
2686
+ saveRegistration(directory, { format: 1, root, projectDir: project, enabled: true, configs });
2687
+ }
2688
+ }
2689
+ async function teardownSessions(directory, root) {
2690
+ const lock = acquireRootLock("hooks", join11(directory, "runtime"));
2691
+ const issues = [];
2692
+ let removed = 0;
2693
+ try {
2694
+ await legacyRegistrations(directory, root);
2695
+ const selected = registrations(directory, (path, error) => {
2696
+ issues.push(
2697
+ `${path}: ${error instanceof Error ? error.message : "Invalid Hook registration."}`
2698
+ );
2699
+ }).filter((item) => !root || item.root === root);
2700
+ for (const registration of selected) {
2701
+ registration.enabled = false;
2702
+ try {
2703
+ saveRegistration(directory, registration);
2704
+ } catch (error) {
2705
+ issues.push(
2706
+ `${registration.root}: ${error instanceof Error ? error.message : "Hook removal failed."}`
2707
+ );
2708
+ continue;
2709
+ }
2710
+ for (const config of registration.configs) {
2711
+ try {
2712
+ if (config.kind === "pi-extension") {
2713
+ if (await removePiExtension(directory, config.path)) removed++;
2714
+ continue;
2715
+ }
2716
+ const original = await readStable(
2717
+ dirname6(config.path),
2718
+ config.path.split(/[\\/]/).at(-1)
2719
+ );
2720
+ const value = JSON.parse(original.bytes.toString("utf8"));
2721
+ if (!value || typeof value !== "object" || Array.isArray(value))
2722
+ throw new Error("Invalid hook configuration.");
2723
+ if (value.hooks !== void 0 && (!value.hooks || typeof value.hooks !== "object" || Array.isArray(value.hooks)))
2724
+ throw new Error("Invalid hooks object.");
2725
+ const hooks = object(value.hooks);
2726
+ if (!removeOwned(hooks, config.agent, directory, registration.root)) continue;
2727
+ if (Object.keys(hooks).length) value.hooks = hooks;
2728
+ else delete value.hooks;
2729
+ const empty = !Object.keys(value).length || config.agent === "cursor" && Object.keys(value).length === 1 && value.version === 1;
2730
+ if (empty && config.created)
2731
+ await removeFile(
2732
+ dirname6(config.path),
2733
+ config.path.split(/[\\/]/).at(-1),
2734
+ original.signature
2735
+ );
2736
+ else if (!original.bytes.equals(Buffer.from(JSON.stringify(value, null, 2) + "\n")))
2737
+ await replaceFile(
2738
+ dirname6(config.path),
2739
+ config.path.split(/[\\/]/).at(-1),
2740
+ Buffer.from(JSON.stringify(value, null, 2) + "\n"),
2741
+ original.signature
2742
+ );
2743
+ if (config.exclude && !await exists(config.path)) {
2744
+ const text = await readFile2(config.exclude.path, "utf8");
2745
+ await writeFile2(config.exclude.path, text.replace(config.exclude.line + "\n", ""));
2746
+ }
2747
+ removed++;
2748
+ } catch (error) {
2749
+ if (!missing2(error))
2750
+ issues.push(
2751
+ `${config.path}: ${error instanceof Error ? error.message : "Hook removal failed."}`
2752
+ );
2753
+ }
2754
+ }
2755
+ }
2756
+ return { removed, issues };
2757
+ } finally {
2758
+ lock.close();
2052
2759
  }
2053
- return { configured, issues };
2054
2760
  }
2055
2761
 
2762
+ // apps/client/sessions/hook-entry.ts
2763
+ import { parseArgs } from "node:util";
2764
+ import { realpath as realpath5 } from "node:fs/promises";
2765
+ import { dirname as dirname7, isAbsolute as isAbsolute4, relative as relative4, resolve as resolve5, sep as sep4 } from "node:path";
2766
+ import { homedir as homedir2 } from "node:os";
2767
+ import { join as join13 } from "node:path";
2768
+
2056
2769
  // apps/client/sessions/hooks.ts
2057
- import { createHash as createHash6, randomUUID as randomUUID4 } from "node:crypto";
2058
- import { mkdir as mkdir5, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
2059
- import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
2060
- import { z as z10 } from "zod";
2061
- var HintSchema = z10.object({
2770
+ import { createHash as createHash7, randomUUID as randomUUID5 } from "node:crypto";
2771
+ import { mkdir as mkdir6, open as open3, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
2772
+ import { isAbsolute as isAbsolute3, join as join12 } from "node:path";
2773
+ import { z as z13 } from "zod";
2774
+ var HintSchema = z13.object({
2062
2775
  agent: AgentSchema,
2063
- path: z10.string().min(1).max(4096).refine(isAbsolute3).optional(),
2064
- nativeId: z10.string().min(1).max(512).optional(),
2065
- projectDir: z10.string().min(1).max(4096).refine(isAbsolute3)
2776
+ path: z13.string().min(1).max(4096).refine(isAbsolute3).optional(),
2777
+ nativeId: z13.string().min(1).max(512).optional(),
2778
+ projectDir: z13.string().min(1).max(4096).refine(isAbsolute3)
2066
2779
  });
2067
2780
  function inbox(directory, root) {
2068
- return join6(directory, "session-hints", createHash6("sha256").update(root).digest("hex"));
2781
+ return join12(directory, "session-hints", createHash7("sha256").update(root).digest("hex"));
2069
2782
  }
2070
2783
  async function recordHint(directory, root, input) {
2071
2784
  const hint = HintSchema.parse(input);
2072
2785
  const targetDir = inbox(directory, root);
2073
- await mkdir5(targetDir, { recursive: true, mode: 448 });
2074
- const target = join6(
2786
+ await mkdir6(targetDir, { recursive: true, mode: 448 });
2787
+ const target = join12(
2075
2788
  targetDir,
2076
- `${createHash6("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
2789
+ `${createHash7("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
2077
2790
  );
2078
- const temporary = `${target}.${randomUUID4()}.tmp`;
2079
- const handle = await open2(temporary, "wx", 384);
2791
+ const temporary = `${target}.${randomUUID5()}.tmp`;
2792
+ const handle = await open3(temporary, "wx", 384);
2080
2793
  try {
2081
2794
  await handle.writeFile(JSON.stringify(hint));
2082
2795
  } finally {
@@ -2097,9 +2810,9 @@ async function consumeHints(directory, root, sessions, refresh) {
2097
2810
  for await (const entry of dir) {
2098
2811
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
2099
2812
  if (count++ >= 50) break;
2100
- const path = join6(inbox(directory, root), entry.name);
2813
+ const path = join12(inbox(directory, root), entry.name);
2101
2814
  try {
2102
- const handle = await open2(path, "r");
2815
+ const handle = await open3(path, "r");
2103
2816
  let body;
2104
2817
  try {
2105
2818
  if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
@@ -2137,353 +2850,104 @@ async function hookPayload() {
2137
2850
  }
2138
2851
  }
2139
2852
 
2140
- // apps/client/sessions/command.ts
2141
- var help = `Usage: teammy session COMMAND [options]
2142
-
2143
- setup Configure project hooks and native discovery (also done by connect)
2144
- track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
2145
- untrack --file PATH (does not delete server history)
2146
- discover --agent AGENT [--directory DIR] [--project-dir DIR]
2147
- undiscover --directory DIR (also untrack any previously discovered sources to stop them)
2148
- sources Show registered sources, discovery directories and collection errors
2149
- hook --agent AGENT [--file PATH] [--project-dir DIR]; accepts JSON on stdin
2150
- list [--after N] [--limit N]
2151
- read --owner MEMBER --key KEY [--revision N]
2152
- search --query TEXT [--after N] [--limit N]
2153
-
2154
- Shared options: --root DIR (./workspaces), --state-dir DIR (~/.teammy/client).
2155
- AGENT: claude-code, codex, cursor, pi, dsh. Project defaults to the shared root's parent.
2156
- track/discover only register local sources. Run teammy sync to collect and publish.
2157
- Discover uses native default locations except DSH, which requires --directory.
2158
- Hook only records a local hint, emits no stdout, and always exits successfully.
2159
- `;
2160
- async function runSessionCommand(args) {
2161
- const command = args[0];
2162
- if (!command || ["--help", "-h", "help"].includes(command)) {
2163
- process.stdout.write(help);
2164
- return 0;
2165
- }
2166
- const { values } = parseArgs({
2167
- args: args.slice(1),
2168
- options: {
2169
- root: { type: "string", default: resolve4("workspaces") },
2170
- "state-dir": { type: "string", default: join7(homedir2(), ".teammy", "client") },
2171
- agent: { type: "string" },
2172
- file: { type: "string" },
2173
- directory: { type: "string" },
2174
- "project-dir": { type: "string" },
2175
- "adopt-existing": { type: "boolean" },
2176
- owner: { type: "string" },
2177
- key: { type: "string" },
2178
- revision: { type: "string" },
2179
- query: { type: "string" },
2180
- after: { type: "string", default: "0" },
2181
- limit: { type: "string", default: "50" },
2182
- help: { type: "boolean", short: "h" }
2183
- }
2184
- });
2185
- if (values.help) {
2186
- process.stdout.write(help);
2187
- return 0;
2188
- }
2189
- const root = await realpath4(resolve4(values.root));
2190
- const directory = await stateLocation(root, values["state-dir"]);
2191
- const projectDir = await realpath4(resolve4(values["project-dir"] ?? dirname5(root)));
2192
- if (command === "hook") {
2193
- const payload = values.file ? {} : await hookPayload();
2194
- const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
2195
- const nativeId = payload.session_id ?? payload.conversation_id;
2196
- if (typeof file === "string" && file || typeof nativeId === "string")
2197
- await recordHint(directory, root, {
2198
- agent: AgentSchema.parse(values.agent),
2199
- ...typeof file === "string" && file ? { path: resolve4(file) } : {},
2200
- ...typeof nativeId === "string" ? { nativeId } : {},
2201
- projectDir
2202
- });
2203
- return 0;
2204
- }
2205
- const state = new ClientState(directory);
2853
+ // apps/client/sessions/hook-entry.ts
2854
+ async function runHook(args) {
2206
2855
  try {
2207
- const binding = state.binding(root);
2208
- if (!binding) throw new Error("Connect this root first.");
2209
- const sessions = new SessionState(state, root);
2210
- const client = new HubClient(binding.serverUrl, 1e4, binding);
2211
- let output;
2212
- if (command === "setup") output = await setupSessions(state, root, directory);
2213
- else if (command === "track" || command === "untrack") {
2214
- if (!values.file) throw new Error("--file is required.");
2215
- const path = resolve4(values.file);
2216
- if (command === "track")
2217
- sessions.track(
2218
- AgentSchema.parse(values.agent),
2219
- path,
2220
- projectDir,
2221
- Boolean(values["adopt-existing"])
2222
- );
2223
- else sessions.untrack(path);
2224
- output = sessions.status();
2225
- } else if (command === "discover" || command === "undiscover") {
2226
- if (command === "undiscover") {
2227
- if (!values.directory) throw new Error("--directory is required.");
2228
- state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath4(resolve4(values.directory)));
2229
- } else {
2230
- const agent = AgentSchema.parse(values.agent);
2231
- const defaults = {
2232
- "claude-code": join7(
2233
- process.env.CLAUDE_CONFIG_DIR ?? join7(homedir2(), ".claude"),
2234
- "projects"
2235
- ),
2236
- codex: join7(process.env.CODEX_HOME ?? join7(homedir2(), ".codex"), "sessions"),
2237
- cursor: join7(
2238
- process.env.CURSOR_CONFIG_DIR ?? join7(homedir2(), ".cursor"),
2239
- "chats",
2240
- createHash7("md5").update(projectDir).digest("hex")
2241
- ),
2242
- pi: join7(process.env.PI_CODING_AGENT_DIR ?? join7(homedir2(), ".pi", "agent"), "sessions"),
2243
- dsh: void 0
2244
- };
2245
- const path = values.directory ?? defaults[agent];
2246
- if (!path) throw new Error("DSH requires its configured persistence --directory.");
2247
- sessions.discover(agent, await realpath4(resolve4(path)), projectDir);
2856
+ const { values } = parseArgs({
2857
+ args,
2858
+ options: {
2859
+ agent: { type: "string" },
2860
+ root: { type: "string" },
2861
+ "state-dir": { type: "string" },
2862
+ "project-dir": { type: "string" },
2863
+ file: { type: "string" },
2864
+ "integration-file": { type: "string" }
2248
2865
  }
2249
- output = sessions.directories();
2250
- } else if (command === "sources")
2251
- output = {
2252
- sources: sessions.status(),
2253
- discovery: sessions.directories(),
2254
- hooks: sessions.hooks()
2255
- };
2256
- else {
2257
- const after = Number(values.after);
2258
- const limit = Number(values.limit);
2259
- if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
2260
- throw new Error("Invalid pagination options.");
2261
- if (command === "list") output = await client.sessions(after, limit);
2262
- else if (command === "search") {
2263
- if (!values.query) throw new Error("--query is required.");
2264
- output = await client.searchSessions(values.query, after, limit);
2265
- } else if (command === "read") {
2266
- if (!values.owner || !values.key) throw new Error("--owner and --key are required.");
2267
- const revision = values.revision === void 0 ? void 0 : Number(values.revision);
2268
- if (revision !== void 0 && (!Number.isSafeInteger(revision) || revision < 1))
2269
- throw new Error("Invalid revision.");
2270
- const session = await client.session(values.owner, values.key, revision);
2271
- const snapshot = SessionSnapshotSchema.parse(
2272
- JSON.parse(Buffer.from(await client.downloadBlob(session.blob.sha256)).toString("utf8"))
2273
- );
2274
- output = { session, snapshot };
2275
- } else throw new Error("Unknown session command. Use teammy session --help.");
2276
- }
2277
- process.stdout.write(`${JSON.stringify(output, null, 2)}
2278
- `);
2279
- return 0;
2280
- } finally {
2281
- state.close();
2282
- }
2283
- }
2284
-
2285
- // apps/client/sync-command.ts
2286
- import { parseArgs as parseArgs3 } from "node:util";
2287
- import { setTimeout as setTimeout4 } from "node:timers/promises";
2288
-
2289
- // apps/client/client-command.ts
2290
- import { parseArgs as parseArgs2 } from "node:util";
2291
- import { join as join12, resolve as resolve6 } from "node:path";
2292
- import { homedir as homedir4 } from "node:os";
2293
- import { createInterface as createInterface2 } from "node:readline/promises";
2294
-
2295
- // apps/client/connection.ts
2296
- import { readFile as readFile2, mkdir as mkdir6 } from "node:fs/promises";
2297
- import { createInterface } from "node:readline/promises";
2298
-
2299
- // apps/client/root-lock.ts
2300
- import { createHash as createHash8 } from "node:crypto";
2301
- import { join as join8 } from "node:path";
2302
- function acquireRootLock(root, stateDirectory) {
2303
- let lock;
2304
- try {
2305
- lock = openDatabase(
2306
- join8(stateDirectory, "locks", `${createHash8("sha256").update(root).digest("hex")}.sqlite3`),
2307
- [],
2308
- 0
2309
- );
2310
- lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
2311
- return lock;
2312
- } catch (error) {
2313
- lock?.close();
2314
- if (error instanceof Error && /locked|busy/.test(error.message))
2315
- throw new Error("Another sync or connect process is using this root.");
2316
- throw error;
2317
- }
2318
- }
2319
-
2320
- // apps/client/local-config.ts
2321
- import { constants as constants3 } from "node:fs";
2322
- import { open as open3, writeFile as writeFile2 } from "node:fs/promises";
2323
- import { join as join9 } from "node:path";
2324
- import { z as z11 } from "zod";
2325
- import ignore from "ignore";
2326
- var LOCAL_CONFIG_NAME = ".teammy-local.json";
2327
- var LocalConfigSchema = z11.object({
2328
- files: z11.object({ ignore: z11.array(z11.string().max(1024)).max(256).default([]) }).strict().default({ ignore: [] })
2329
- }).strict();
2330
- async function ensureLocalConfig(root) {
2331
- try {
2332
- await writeFile2(
2333
- join9(root, LOCAL_CONFIG_NAME),
2334
- JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
2335
- { flag: "wx", mode: 384 }
2336
- );
2337
- } catch (error) {
2338
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
2339
- }
2340
- }
2341
- async function loadLocalRules(root) {
2342
- let rules = [];
2343
- try {
2344
- const file = await open3(
2345
- join9(root, LOCAL_CONFIG_NAME),
2346
- constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
2866
+ });
2867
+ const directory = await realpath5(
2868
+ resolve5(values["state-dir"] ?? join13(homedir2(), ".teammy", "client"))
2347
2869
  );
2348
- try {
2349
- const before = await file.stat();
2350
- if (!before.isFile() || before.size > 65536)
2351
- throw new Error("Expected a regular configuration file no larger than 64 KiB.");
2352
- const buffer = Buffer.alloc(65537);
2353
- const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
2354
- if (bytesRead > 65536 || bytesRead !== before.size)
2355
- throw new Error("Configuration exceeds the read limit or changed while reading.");
2356
- const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
2357
- const after = await file.stat();
2358
- if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
2359
- throw new Error("Configuration is being saved; retry on the next check.");
2360
- rules = LocalConfigSchema.parse(JSON.parse(text)).files.ignore;
2361
- } finally {
2362
- await file.close();
2363
- }
2364
- } catch (error) {
2365
- if (!missing(error))
2366
- throw new Error(
2367
- `${LOCAL_CONFIG_NAME}: invalid or unreadable configuration; uploads paused.`,
2368
- { cause: error }
2369
- );
2370
- }
2371
- const matcher = ignore().add(rules);
2372
- return {
2373
- fingerprint: JSON.stringify(rules),
2374
- excludes: (path, directory = false) => ignored(path) || matcher.ignores(path + (directory && !path.endsWith("/") ? "/" : ""))
2375
- };
2376
- }
2377
-
2378
- // apps/client/connection.ts
2379
- async function connectProject(state, root, directory, options) {
2380
- const lock = acquireRootLock(root, directory);
2381
- try {
2382
- state.assertRootAvailable(root);
2383
- const transport = new HubClient(options.url);
2384
- const existing = state.binding(root);
2385
- const project = new ClientProjectState(state, root);
2386
- if (existing && existing.serverUrl !== transport.url)
2387
- throw new Error("This root is connected to another server.");
2388
- if (existing && options.project && options.project !== existing.workspaceId)
2389
- throw new Error("This root belongs to a different project. Use a different local root.");
2390
- const info = await transport.serverInfo();
2391
- if (!info.capabilities.includes("multi-project"))
2392
- throw new Error(
2393
- "This client requires a multi-project server. Update the server before connecting."
2870
+ const payload = values.file ? {} : await hookPayload();
2871
+ let selectedRoot = values.root;
2872
+ if (values["integration-file"]) {
2873
+ if (values.agent !== "pi" || typeof payload.cwd !== "string") return;
2874
+ const cwd = await realpath5(payload.cwd);
2875
+ const candidates2 = registrations(directory, () => {
2876
+ }).filter(
2877
+ (item) => item.enabled && item.configs.some(
2878
+ (config) => config.kind === "pi-extension" && config.path === values["integration-file"]
2879
+ )
2394
2880
  );
2395
- let member = options.member;
2396
- if (existing && !options.reconnect && !options.tokenFile) {
2397
- if (member && member !== existing.memberId)
2398
- throw new Error("This root belongs to a different member.");
2399
- const client2 = new HubClient(existing.serverUrl, 1e4, existing);
2400
- await client2.members();
2401
- } else if (options.tokenFile) {
2402
- const token = CredentialSchema.parse((await readFile2(options.tokenFile, "utf8")).trim());
2403
- const deviceId = state.deviceFor(root);
2404
- const client2 = new HubClient(options.url, 1e4, { token, deviceId });
2405
- const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
2406
- if (options.project && binding2.workspaceId !== options.project)
2407
- throw new Error("Token belongs to a different project.");
2408
- state.saveBinding(root, client2.url, token, binding2);
2409
- } else {
2410
- member ??= existing?.memberId;
2411
- if (!member && process.stdin.isTTY) {
2412
- const rl = createInterface({ input: process.stdin, output: process.stderr });
2881
+ const matches = [];
2882
+ for (const item of candidates2) {
2413
2883
  try {
2414
- member = (await rl.question("Member ID: ")).trim();
2415
- } finally {
2416
- rl.close();
2884
+ const project = await realpath5(item.projectDir);
2885
+ const rel = relative4(project, cwd);
2886
+ if (!isAbsolute4(rel) && rel !== ".." && !rel.startsWith(".." + sep4)) matches.push(item);
2887
+ } catch {
2417
2888
  }
2418
2889
  }
2419
- if (!member) throw new Error("--member is required when input is not interactive.");
2420
- NameSchema.parse(member);
2421
- if (existing && existing.memberId !== member)
2422
- throw new Error("This root belongs to a different member.");
2423
- const projectId = options.project ?? existing?.workspaceId;
2424
- if (!projectId)
2425
- throw new Error(
2426
- "--project is required for a new connection; copy the command from the project settings."
2427
- );
2428
- NameSchema.parse(projectId);
2429
- const request = project.enrollment(
2430
- transport.url,
2431
- member,
2432
- projectId,
2433
- Boolean(options.reconnect)
2434
- );
2435
- const binding2 = await transport.enroll(request);
2436
- state.saveBinding(root, transport.url, request.credential, binding2);
2437
- project.completeEnrollment();
2890
+ matches.sort((a, b) => b.projectDir.length - a.projectDir.length);
2891
+ selectedRoot = matches[0]?.root;
2892
+ if (!selectedRoot) return;
2438
2893
  }
2439
- const binding = state.binding(root);
2440
- const client = new HubClient(binding.serverUrl, 1e4, binding);
2441
- if (info.capabilities.includes("project-config")) {
2442
- try {
2443
- await applyProjectFile(project, await client.projectConfig());
2444
- } catch (error) {
2445
- if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
2446
- throw error;
2447
- }
2894
+ const root = await realpath5(resolve5(selectedRoot ?? "workspaces"));
2895
+ const registration = readRegistration(directory, root);
2896
+ if (!registration?.enabled) return;
2897
+ const projectDir = await realpath5(registration.projectDir);
2898
+ if (projectDir !== await realpath5(values["project-dir"] ?? dirname7(root))) return;
2899
+ if (typeof payload.cwd === "string") {
2900
+ const rel = relative4(projectDir, await realpath5(payload.cwd));
2901
+ if (isAbsolute4(rel) || rel === ".." || rel.startsWith(".." + sep4)) return;
2448
2902
  }
2449
- const own = await metadata(root, binding.memberId);
2450
- if (own && !own.directory) throw new Error("Member path is not a directory.");
2451
- if (!own) await mkdir6(await safePath(root, binding.memberId));
2452
- await ensureLocalConfig(root);
2453
- const { token: _, ...visible } = binding;
2454
- return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
2455
- } finally {
2456
- lock.close();
2903
+ const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
2904
+ const nativeId = payload.session_id ?? payload.conversation_id;
2905
+ if (typeof file === "string" && file || typeof nativeId === "string")
2906
+ await recordHint(directory, root, {
2907
+ agent: values.agent,
2908
+ ...typeof file === "string" && file ? { path: resolve5(projectDir, file) } : {},
2909
+ ...typeof nativeId === "string" ? { nativeId } : {},
2910
+ projectDir
2911
+ });
2912
+ } catch {
2457
2913
  }
2458
2914
  }
2459
2915
 
2916
+ // apps/client/runtime/maintenance.ts
2917
+ import { mkdir as mkdir9, rename as rename4, rm as rm2, readFile as readFile4 } from "node:fs/promises";
2918
+ import { existsSync as existsSync4 } from "node:fs";
2919
+ import { join as join16, resolve as resolve7 } from "node:path";
2920
+ import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
2921
+ import { setTimeout as delay } from "node:timers/promises";
2922
+
2460
2923
  // apps/client/runtime/ipc.ts
2461
2924
  import { createConnection } from "node:net";
2462
2925
  import { mkdir as mkdir7, lstat as lstat3, chmod } from "node:fs/promises";
2463
- import { join as join10 } from "node:path";
2464
- import { z as z12 } from "zod";
2465
- var RequestSchema = z12.object({
2466
- method: z12.enum(["ping", "wake", "status", "once"]),
2467
- root: z12.string().max(4096).optional()
2926
+ import { join as join14 } from "node:path";
2927
+ import { z as z14 } from "zod";
2928
+ var RequestSchema = z14.object({
2929
+ method: z14.enum(["ping", "wake", "status", "once"]),
2930
+ root: z14.string().max(4096).optional()
2468
2931
  }).strict();
2469
2932
  function socketPath(directory) {
2470
- return join10(
2933
+ return join14(
2471
2934
  "/tmp",
2472
2935
  `teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
2473
2936
  "control.sock"
2474
2937
  );
2475
2938
  }
2476
2939
  async function prepareSocketDirectory(directory) {
2477
- const path = join10(socketPath(directory), "..");
2940
+ const path = join14(socketPath(directory), "..");
2478
2941
  await mkdir7(path, { recursive: true, mode: 448 });
2479
2942
  const stat2 = await lstat3(path);
2480
2943
  if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
2481
2944
  throw new Error("Unsafe daemon socket directory.");
2482
2945
  await chmod(path, 448);
2483
2946
  }
2947
+ var CONTROL_PROTOCOL = 1;
2484
2948
  async function daemonRequest(directory, input, timeout = 15e3) {
2485
2949
  RequestSchema.parse(input);
2486
- return new Promise((resolve10, reject) => {
2950
+ return new Promise((resolve16, reject) => {
2487
2951
  const socket = createConnection(socketPath(directory));
2488
2952
  socket.setEncoding("utf8");
2489
2953
  let body = "";
@@ -2493,11 +2957,15 @@ async function daemonRequest(directory, input, timeout = 15e3) {
2493
2957
  done = true;
2494
2958
  socket.destroy();
2495
2959
  if (error) reject(error);
2496
- else resolve10(result2);
2960
+ else resolve16(result2);
2497
2961
  };
2498
2962
  socket.setTimeout(
2499
2963
  timeout,
2500
- () => finish(new Error("Background service did not respond. Run teammy status."))
2964
+ () => finish(
2965
+ Object.assign(new Error("Background service did not respond. Run teammy repair."), {
2966
+ code: "ETIMEDOUT"
2967
+ })
2968
+ )
2501
2969
  );
2502
2970
  socket.on("error", (e) => finish(e));
2503
2971
  socket.on("end", () => {
@@ -2521,10 +2989,28 @@ async function daemonRequest(directory, input, timeout = 15e3) {
2521
2989
  });
2522
2990
  });
2523
2991
  }
2992
+ async function probeDaemon(directory) {
2993
+ try {
2994
+ const info = await daemonRequest(directory, { method: "ping" }, 1e3);
2995
+ if (!info || !Number.isSafeInteger(info.pid) || info.pid <= 0 || typeof info.version !== "string" || info.protocol !== void 0 && info.protocol !== CONTROL_PROTOCOL)
2996
+ return { state: "incompatible", error: "Unsupported background control protocol." };
2997
+ return { state: "ready", info };
2998
+ } catch (error) {
2999
+ if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
3000
+ String(error.code)
3001
+ ))
3002
+ return { state: "absent" };
3003
+ return {
3004
+ state: error.code === "ETIMEDOUT" ? "unresponsive" : "incompatible",
3005
+ error: error instanceof Error ? error.message : "Invalid background response."
3006
+ };
3007
+ }
3008
+ }
2524
3009
  async function runningDaemon(directory) {
2525
3010
  try {
2526
3011
  return await daemonRequest(directory, { method: "ping" }, 1e3);
2527
3012
  } catch (error) {
3013
+ if (error instanceof Error && error.message === "Background service is stopping.") return null;
2528
3014
  if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
2529
3015
  String(error.code)
2530
3016
  ))
@@ -2537,8 +3023,8 @@ async function runningDaemon(directory) {
2537
3023
  import { execFile as execFile3 } from "node:child_process";
2538
3024
  import { promisify as promisify3 } from "node:util";
2539
3025
  import { homedir as homedir3 } from "node:os";
2540
- import { dirname as dirname6, join as join11, resolve as resolve5 } from "node:path";
2541
- import { mkdir as mkdir8, readFile as readFile3, writeFile as writeFile3, rename as rename3 } from "node:fs/promises";
3026
+ import { dirname as dirname8, join as join15, resolve as resolve6 } from "node:path";
3027
+ import { mkdir as mkdir8, readFile as readFile3, writeFile as writeFile3, rename as rename3, rm } from "node:fs/promises";
2542
3028
  import { setTimeout as setTimeout3 } from "node:timers/promises";
2543
3029
  var execute = promisify3(execFile3);
2544
3030
  var exec3 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
@@ -2555,7 +3041,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
2555
3041
  if (platform === "darwin")
2556
3042
  return {
2557
3043
  id,
2558
- path: join11(userHome, "Library", "LaunchAgents", id + ".plist"),
3044
+ path: join15(userHome, "Library", "LaunchAgents", id + ".plist"),
2559
3045
  content: `<?xml version="1.0" encoding="UTF-8"?>
2560
3046
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2561
3047
  <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>
@@ -2564,7 +3050,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
2564
3050
  if (platform === "linux")
2565
3051
  return {
2566
3052
  id: id + ".service",
2567
- path: join11(userHome, ".config", "systemd", "user", id + ".service"),
3053
+ path: join15(userHome, ".config", "systemd", "user", id + ".service"),
2568
3054
  content: `[Unit]
2569
3055
  Description=Teammy background synchronization
2570
3056
  [Service]
@@ -2610,17 +3096,19 @@ async function bootstrapLaunchAgent(path) {
2610
3096
  }
2611
3097
  }
2612
3098
  async function ensureDaemon(directory, version) {
2613
- const definition = serviceDefinition(directory, process.execPath, resolve5(process.argv[1]));
3099
+ const definition = serviceDefinition(directory, process.execPath, resolve6(process.argv[1]));
2614
3100
  let old = "";
2615
3101
  try {
2616
3102
  old = await readFile3(definition.path, "utf8");
2617
3103
  } catch (e) {
2618
3104
  if (e.code !== "ENOENT") throw e;
2619
3105
  }
2620
- const alive = await runningDaemon(directory);
2621
- if (alive && old === definition.content && alive.version === version) return;
3106
+ const probe = await probeDaemon(directory);
3107
+ const alive = probe.state === "ready" ? probe.info : null;
3108
+ if (alive && old === definition.content && alive.version === version && alive.protocol === CONTROL_PROTOCOL)
3109
+ return;
2622
3110
  await checkServiceSupport();
2623
- await mkdir8(dirname6(definition.path), { recursive: true });
3111
+ await mkdir8(dirname8(definition.path), { recursive: true });
2624
3112
  if (old !== definition.content) {
2625
3113
  const temp = definition.path + `.${process.pid}.tmp`;
2626
3114
  await writeFile3(temp, definition.content, { mode: 384 });
@@ -2634,33 +3122,528 @@ async function ensureDaemon(directory, version) {
2634
3122
  loaded = true;
2635
3123
  } catch {
2636
3124
  }
2637
- if (loaded && (old !== definition.content || alive?.version !== version)) {
2638
- await exec3("launchctl", ["bootout", target]);
2639
- loaded = false;
3125
+ if (loaded && (old !== definition.content || alive?.version !== version || alive?.protocol !== CONTROL_PROTOCOL)) {
3126
+ await exec3("launchctl", ["bootout", target]);
3127
+ loaded = false;
3128
+ }
3129
+ if (!loaded) await bootstrapLaunchAgent(definition.path);
3130
+ await exec3("launchctl", ["kickstart", target]);
3131
+ } else {
3132
+ await exec3("systemctl", ["--user", "daemon-reload"]);
3133
+ await exec3("systemctl", ["--user", "enable", definition.id]);
3134
+ await exec3("systemctl", [
3135
+ "--user",
3136
+ old !== definition.content || alive?.version !== version || alive?.protocol !== CONTROL_PROTOCOL ? "restart" : "start",
3137
+ definition.id
3138
+ ]);
3139
+ }
3140
+ const deadline = Date.now() + 15e3;
3141
+ while (Date.now() < deadline) {
3142
+ const current = await probeDaemon(directory);
3143
+ if (current.state === "ready" && current.info.version === version && current.info.protocol === CONTROL_PROTOCOL)
3144
+ return;
3145
+ await setTimeout3(150);
3146
+ }
3147
+ throw new Error(
3148
+ "Background service did not start. Run teammy logs and check the user service manager."
3149
+ );
3150
+ }
3151
+ async function wakeDaemon(directory, version) {
3152
+ await ensureDaemon(directory, version);
3153
+ await daemonRequest(directory, { method: "wake" });
3154
+ }
3155
+ async function stopDaemon(directory, remove = false) {
3156
+ const definition = serviceDefinition(directory, process.execPath, resolve6(process.argv[1]));
3157
+ let loaded = false;
3158
+ let pid;
3159
+ try {
3160
+ const saved = JSON.parse(await readFile3(join15(directory, "runtime", "daemon.json"), "utf8"));
3161
+ if (saved.directory === directory && Number.isSafeInteger(saved.pid) && saved.pid > 0)
3162
+ pid = saved.pid;
3163
+ } catch {
3164
+ }
3165
+ const target = process.platform === "darwin" ? `gui/${process.getuid()}/${definition.id}` : definition.id;
3166
+ try {
3167
+ const output = process.platform === "darwin" ? (await exec3("launchctl", ["print", target])).stdout : (await exec3("systemctl", ["--user", "show", target, "--property=LoadState,MainPID"])).stdout;
3168
+ loaded = process.platform === "darwin" || !output.includes("LoadState=not-found");
3169
+ const match = output.match(process.platform === "darwin" ? /\bpid = (\d+)/ : /MainPID=(\d+)/);
3170
+ if (match && Number(match[1]) > 0) pid = Number(match[1]);
3171
+ } catch {
3172
+ }
3173
+ if (loaded) {
3174
+ try {
3175
+ if (process.platform === "darwin")
3176
+ await exec3("launchctl", ["bootout", target], { timeout: 3e4 });
3177
+ else await exec3("systemctl", ["--user", "stop", target], { timeout: 3e4 });
3178
+ } catch {
3179
+ if (process.platform === "darwin") {
3180
+ await exec3("launchctl", ["kill", "SIGKILL", target]);
3181
+ await exec3("launchctl", ["bootout", target], { timeout: 5e3 });
3182
+ } else {
3183
+ await exec3("systemctl", ["--user", "kill", "--signal=SIGKILL", "--kill-whom=all", target]);
3184
+ await exec3("systemctl", ["--user", "stop", target], { timeout: 5e3 });
3185
+ }
3186
+ }
3187
+ if (process.platform === "linux") await exec3("systemctl", ["--user", "disable", target]);
3188
+ }
3189
+ const probe = await probeDaemon(directory);
3190
+ if (probe.state === "ready") pid = probe.info.pid;
3191
+ if (pid) {
3192
+ let command = "";
3193
+ try {
3194
+ command = (await exec3("ps", ["-p", String(pid), "-o", "uid=", "-o", "command="])).stdout;
3195
+ } catch {
3196
+ }
3197
+ if (command.trim()) {
3198
+ const owned = Number(command.trim().split(/\s+/)[0]) === process.getuid?.() && command.includes("_daemon") && command.includes(directory);
3199
+ if (!owned) {
3200
+ if (probe.state !== "absent")
3201
+ throw new Error(
3202
+ "Cannot verify background process ownership; no unrelated process was stopped."
3203
+ );
3204
+ command = "";
3205
+ }
3206
+ if (owned) {
3207
+ try {
3208
+ process.kill(pid, "SIGTERM");
3209
+ } catch (e) {
3210
+ if (e.code !== "ESRCH") throw e;
3211
+ }
3212
+ const deadline2 = Date.now() + 3e4;
3213
+ while (Date.now() < deadline2) {
3214
+ try {
3215
+ process.kill(pid, 0);
3216
+ } catch {
3217
+ break;
3218
+ }
3219
+ await setTimeout3(100);
3220
+ }
3221
+ let current = "";
3222
+ try {
3223
+ current = (await exec3("ps", ["-p", String(pid), "-o", "uid=", "-o", "command="])).stdout;
3224
+ } catch {
3225
+ }
3226
+ if (current === command && current.trim()) {
3227
+ try {
3228
+ process.kill(pid, "SIGKILL");
3229
+ } catch (e) {
3230
+ if (e.code !== "ESRCH") throw e;
3231
+ }
3232
+ }
3233
+ }
3234
+ }
3235
+ }
3236
+ const deadline = Date.now() + 5e3;
3237
+ for (; ; ) {
3238
+ try {
3239
+ const lock = acquireRootLock("daemon:" + directory, directory);
3240
+ lock.close();
3241
+ break;
3242
+ } catch (error) {
3243
+ if (Date.now() >= deadline) throw error;
3244
+ await setTimeout3(100);
3245
+ }
3246
+ }
3247
+ const remaining = await probeDaemon(directory);
3248
+ if (remaining.state !== "absent")
3249
+ throw new Error("Background service has not stopped. Inspect the user service manager.");
3250
+ await rm(socketPath(directory), { force: true });
3251
+ if (remove || process.platform === "darwin") await rm(definition.path, { force: true });
3252
+ }
3253
+
3254
+ // apps/client/runtime/maintenance.ts
3255
+ var services = { stop: stopDaemon, start: ensureDaemon };
3256
+ async function prepareClient(directory, version, options = {}, control = services) {
3257
+ const record = options.stop || options.uninstall ? null : readMaintenance(directory);
3258
+ if (options.daemonStartup && record)
3259
+ throw new Error("Client is paused for maintenance. Run teammy repair.");
3260
+ const schema = options.stop || options.uninstall ? void 0 : stateVersion(directory);
3261
+ const installation = {
3262
+ version,
3263
+ executable: process.execPath,
3264
+ entry: resolve7(process.argv[1]),
3265
+ protocol: CONTROL_PROTOCOL,
3266
+ schema: clientSchemaVersion
3267
+ };
3268
+ let installed;
3269
+ try {
3270
+ installed = JSON.parse(await readFile4(join16(directory, "runtime/installation.json"), "utf8"));
3271
+ } catch {
3272
+ }
3273
+ const probe = options.daemonStartup ? { state: "absent" } : await probeDaemon(directory);
3274
+ const changed = probe.state === "ready" && (probe.info.version !== version || probe.info.protocol !== CONTROL_PROTOCOL || probe.info.executable !== process.execPath || probe.info.entry !== resolve7(process.argv[1]));
3275
+ const required = options.force || options.stop || options.uninstall || schema !== clientSchemaVersion || JSON.stringify(installed) !== JSON.stringify(installation) || changed || probe.state === "unresponsive" || probe.state === "incompatible" || Boolean(record && (options.resume || ["repair", "upgrade"].includes(record.reason)));
3276
+ if (!required) return { maintained: false, enabled: 0, stopped: 0, issues: [] };
3277
+ const mutex = acquireRootLock("maintenance", join16(directory, "runtime"));
3278
+ const log = new ClientLog(directory);
3279
+ let access2;
3280
+ let legacy;
3281
+ let state;
3282
+ let enabled = 0, stopped = 0;
3283
+ let restart = false;
3284
+ const issues = [];
3285
+ const pauseReason = options.resume ? void 0 : record?.reason === "paused" || record?.reason === "uninstalled" ? record.reason : record?.pauseReason;
3286
+ try {
3287
+ const reason = options.uninstall ? "uninstalled" : options.stop ? "paused" : options.force ? "repair" : "upgrade";
3288
+ writeMaintenance(directory, {
3289
+ reason,
3290
+ phase: "stopping",
3291
+ version,
3292
+ ...pauseReason ? { pauseReason } : {}
3293
+ });
3294
+ log.write("maintenance.started", void 0, `version=${version} reason=${reason}`);
3295
+ if (!options.daemonStartup) await control.stop(directory, Boolean(options.uninstall));
3296
+ if (options.stop || options.uninstall) {
3297
+ if (options.uninstall) issues.push(...(await teardownSessions(directory)).issues);
3298
+ writeMaintenance(directory, { reason, phase: "paused", version });
3299
+ log.write("maintenance.paused");
3300
+ } else {
3301
+ const deadline = Date.now() + 5e3;
3302
+ while (!access2) {
3303
+ try {
3304
+ access2 = stateAccess(directory, true);
3305
+ } catch (error) {
3306
+ if (!(error instanceof Error) || !error.message.includes("Client state is in use") || Date.now() >= deadline)
3307
+ throw error;
3308
+ await delay(100);
3309
+ }
3310
+ }
3311
+ legacy = legacyStateLocks(directory);
3312
+ const before = stateVersion(directory);
3313
+ if (before > clientSchemaVersion)
3314
+ throw new Error(
3315
+ "Database was created by a newer Teammy version. Install a compatible client; teammy repair --stop and logs remain available."
3316
+ );
3317
+ let backup;
3318
+ if (before > 0 && before < clientSchemaVersion) {
3319
+ const backups = join16(directory, "backups");
3320
+ await mkdir9(backups, { recursive: true, mode: 448 });
3321
+ backup = join16(backups, `before-schema-${before}.sqlite3`);
3322
+ if (!existsSync4(backup)) {
3323
+ const temp = backup + ".tmp";
3324
+ await rm2(temp, { force: true });
3325
+ const db = new DatabaseSync5(join16(directory, "client.sqlite3"));
3326
+ try {
3327
+ db.prepare("VACUUM INTO ?").run(temp);
3328
+ } finally {
3329
+ db.close();
3330
+ }
3331
+ const { chmod: chmod3 } = await import("node:fs/promises");
3332
+ await chmod3(temp, 384);
3333
+ await rename4(temp, backup);
3334
+ }
3335
+ log.write("maintenance.backup", void 0, backup);
3336
+ }
3337
+ writeMaintenance(directory, {
3338
+ reason,
3339
+ phase: "migrating",
3340
+ version,
3341
+ ...pauseReason ? { pauseReason } : {},
3342
+ ...backup ? { backup } : {}
3343
+ });
3344
+ openDatabase(join16(directory, "client.sqlite3"), clientMigrations).close();
3345
+ state = new ClientState(directory, { maintenance: true });
3346
+ const runtime = new RuntimeState(state);
3347
+ if (options.intent) {
3348
+ if (options.intent.enabled) await verifyWorkspace(state, options.intent.root);
3349
+ runtime.enable(options.intent.root, options.intent.enabled);
3350
+ }
3351
+ writeMaintenance(directory, {
3352
+ reason,
3353
+ phase: "configuring",
3354
+ version,
3355
+ ...pauseReason ? { pauseReason } : {},
3356
+ ...backup ? { backup } : {}
3357
+ });
3358
+ for (const { root } of state.db.prepare("SELECT root FROM connections WHERE binding_json IS NOT NULL").all()) {
3359
+ if (runtime.enabled(String(root))) enabled++;
3360
+ else stopped++;
3361
+ try {
3362
+ issues.push(...(await setupSessions(state, String(root), directory)).issues);
3363
+ } catch (error) {
3364
+ issues.push(`${root}: ${diagnostic(error)}`);
3365
+ }
3366
+ }
3367
+ writePrivateJson(join16(directory, "runtime/installation.json"), installation);
3368
+ if (pauseReason)
3369
+ writeMaintenance(directory, { reason: pauseReason, phase: "paused", version });
3370
+ else await rm2(maintenancePath(directory), { force: true });
3371
+ restart = !options.daemonStartup && !pauseReason && enabled > 0;
3372
+ if (backup) {
3373
+ const { readdir: readdir3 } = await import("node:fs/promises");
3374
+ for (const file of await readdir3(join16(directory, "backups"))) {
3375
+ if (/^before-schema-\d+\.sqlite3$/.test(file) && join16(directory, "backups", file) !== backup)
3376
+ await rm2(join16(directory, "backups", file));
3377
+ }
3378
+ }
3379
+ log.write(
3380
+ "maintenance.completed",
3381
+ void 0,
3382
+ `version=${version} enabled=${enabled} issues=${issues.length}`
3383
+ );
3384
+ }
3385
+ for (const issue of issues) log.write("sessions.setup.failed", void 0, issue);
3386
+ } catch (error) {
3387
+ log.write("maintenance.failed", void 0, diagnostic(error));
3388
+ throw error;
3389
+ } finally {
3390
+ state?.close();
3391
+ legacy?.close();
3392
+ access2?.close();
3393
+ mutex.close();
3394
+ }
3395
+ if (restart) await control.start(directory, version);
3396
+ return { maintained: true, enabled, stopped, issues };
3397
+ }
3398
+
3399
+ // apps/client/sessions/command.ts
3400
+ var help = `Usage: teammy session COMMAND [options]
3401
+
3402
+ Collect Agent conversations into the conversation library (\u5BF9\u8BDD\u5E93), then browse or search them.
3403
+
3404
+ teardown Remove project hooks; --all removes all registered hooks (keeps discovery)
3405
+ setup Configure project hooks and native discovery (also done by connect)
3406
+ track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
3407
+ untrack --file PATH (does not delete server history)
3408
+ discover --agent AGENT [--directory DIR] [--project-dir DIR]
3409
+ undiscover --directory DIR (also untrack any previously discovered sources to stop them)
3410
+ sources Show registered sources, discovery directories and collection errors
3411
+ hook --agent AGENT [--file PATH] [--project-dir DIR]; accepts JSON on stdin
3412
+ list [--after N] [--limit N]
3413
+ read --owner MEMBER --key KEY [--revision N]
3414
+ search --query TEXT [--after N] [--limit N]
3415
+
3416
+ Shared options: --root DIR (./workspaces), --state-dir DIR (~/.teammy/client).
3417
+ AGENT: claude-code, codex, cursor, pi, dsh. Project defaults to the shared root's parent.
3418
+ track/discover only register local sources. Run teammy sync to collect and publish.
3419
+ Discover uses native default locations except DSH, which requires --directory.
3420
+ Hook only records a local hint, emits no stdout, and always exits successfully.
3421
+ `;
3422
+ async function runSessionCommand(args, version) {
3423
+ const command = args[0];
3424
+ if (!command || ["--help", "-h", "help"].includes(command)) {
3425
+ process.stdout.write(help);
3426
+ return 0;
3427
+ }
3428
+ if (command === "hook") {
3429
+ await runHook(args.slice(1));
3430
+ return 0;
3431
+ }
3432
+ const { values } = parseArgs2({
3433
+ args: args.slice(1),
3434
+ options: {
3435
+ root: { type: "string", default: resolve8("workspaces") },
3436
+ "state-dir": { type: "string", default: join17(homedir4(), ".teammy", "client") },
3437
+ agent: { type: "string" },
3438
+ file: { type: "string" },
3439
+ directory: { type: "string" },
3440
+ "project-dir": { type: "string" },
3441
+ "adopt-existing": { type: "boolean" },
3442
+ owner: { type: "string" },
3443
+ key: { type: "string" },
3444
+ revision: { type: "string" },
3445
+ query: { type: "string" },
3446
+ after: { type: "string", default: "0" },
3447
+ limit: { type: "string", default: "50" },
3448
+ help: { type: "boolean", short: "h" },
3449
+ all: { type: "boolean" }
3450
+ }
3451
+ });
3452
+ if (values.help) {
3453
+ process.stdout.write(help);
3454
+ return 0;
3455
+ }
3456
+ if (command === "teardown") {
3457
+ const directory2 = await realpath6(resolve8(values["state-dir"]));
3458
+ const root2 = values.all ? void 0 : await realpath6(resolve8(values.root)).catch(() => resolve8(values.root));
3459
+ const report = await teardownSessions(directory2, root2);
3460
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
3461
+ return report.issues.length ? 1 : 0;
3462
+ }
3463
+ const root = await realpath6(resolve8(values.root));
3464
+ const directory = await stateLocation(root, values["state-dir"]);
3465
+ const projectDir = await realpath6(resolve8(values["project-dir"] ?? dirname9(root)));
3466
+ await prepareClient(directory, version);
3467
+ const state = new ClientState(directory);
3468
+ try {
3469
+ const binding = state.binding(root);
3470
+ if (!binding) throw new Error("Connect this root first.");
3471
+ const sessions = new SessionState(state, root);
3472
+ const client = new HubClient(binding.serverUrl, 1e4, binding);
3473
+ let output;
3474
+ if (command === "setup") output = await setupSessions(state, root, directory, { enable: true });
3475
+ else if (command === "track" || command === "untrack") {
3476
+ if (!values.file) throw new Error("--file is required.");
3477
+ const path = resolve8(values.file);
3478
+ if (command === "track")
3479
+ sessions.track(
3480
+ AgentSchema.parse(values.agent),
3481
+ path,
3482
+ projectDir,
3483
+ Boolean(values["adopt-existing"])
3484
+ );
3485
+ else sessions.untrack(path);
3486
+ output = sessions.status();
3487
+ } else if (command === "discover" || command === "undiscover") {
3488
+ if (command === "undiscover") {
3489
+ if (!values.directory) throw new Error("--directory is required.");
3490
+ const source = await realpath6(resolve8(values.directory));
3491
+ setDiscoveryPreference(directory, root, source, false);
3492
+ state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, source);
3493
+ } else {
3494
+ const agent = AgentSchema.parse(values.agent);
3495
+ const defaults = {
3496
+ "claude-code": join17(
3497
+ process.env.CLAUDE_CONFIG_DIR ?? join17(homedir4(), ".claude"),
3498
+ "projects"
3499
+ ),
3500
+ codex: join17(process.env.CODEX_HOME ?? join17(homedir4(), ".codex"), "sessions"),
3501
+ cursor: join17(
3502
+ process.env.CURSOR_CONFIG_DIR ?? join17(homedir4(), ".cursor"),
3503
+ "chats",
3504
+ createHash8("md5").update(projectDir).digest("hex")
3505
+ ),
3506
+ pi: join17(process.env.PI_CODING_AGENT_DIR ?? join17(homedir4(), ".pi", "agent"), "sessions"),
3507
+ dsh: void 0
3508
+ };
3509
+ const path = values.directory ?? defaults[agent];
3510
+ if (!path) throw new Error("DSH requires its configured persistence --directory.");
3511
+ const source = await realpath6(resolve8(path));
3512
+ setDiscoveryPreference(directory, root, source, true);
3513
+ sessions.discover(agent, source, projectDir);
3514
+ }
3515
+ output = sessions.directories();
3516
+ } else if (command === "sources")
3517
+ output = {
3518
+ sources: sessions.status(),
3519
+ discovery: sessions.directories(),
3520
+ hooks: sessions.hooks()
3521
+ };
3522
+ else {
3523
+ const after = Number(values.after);
3524
+ const limit = Number(values.limit);
3525
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
3526
+ throw new Error("Invalid pagination options.");
3527
+ if (command === "list") output = await client.sessions(after, limit);
3528
+ else if (command === "search") {
3529
+ if (!values.query) throw new Error("--query is required.");
3530
+ output = await client.searchSessions(values.query, after, limit);
3531
+ } else if (command === "read") {
3532
+ if (!values.owner || !values.key) throw new Error("--owner and --key are required.");
3533
+ const revision = values.revision === void 0 ? void 0 : Number(values.revision);
3534
+ if (revision !== void 0 && (!Number.isSafeInteger(revision) || revision < 1))
3535
+ throw new Error("Invalid revision.");
3536
+ const session = await client.session(values.owner, values.key, revision);
3537
+ const snapshot = SessionSnapshotSchema.parse(
3538
+ JSON.parse(Buffer.from(await client.downloadBlob(session.blob.sha256)).toString("utf8"))
3539
+ );
3540
+ output = { session, snapshot };
3541
+ } else throw new Error("Unknown session command. Use teammy session --help.");
3542
+ }
3543
+ process.stdout.write(`${JSON.stringify(output, null, 2)}
3544
+ `);
3545
+ return 0;
3546
+ } finally {
3547
+ state.close();
3548
+ }
3549
+ }
3550
+
3551
+ // apps/client/sync-command.ts
3552
+ import { parseArgs as parseArgs4 } from "node:util";
3553
+ import { setTimeout as setTimeout4 } from "node:timers/promises";
3554
+
3555
+ // apps/client/client-command.ts
3556
+ import { parseArgs as parseArgs3 } from "node:util";
3557
+ import { join as join18, resolve as resolve10 } from "node:path";
3558
+ import { homedir as homedir5 } from "node:os";
3559
+ import { createInterface as createInterface2 } from "node:readline/promises";
3560
+
3561
+ // apps/client/connection.ts
3562
+ import { readFile as readFile5, mkdir as mkdir10 } from "node:fs/promises";
3563
+ import { createInterface } from "node:readline/promises";
3564
+ async function connectProject(state, root, directory, options) {
3565
+ const lock = acquireRootLock(root, directory);
3566
+ try {
3567
+ state.assertRootAvailable(root);
3568
+ const transport = new HubClient(options.url);
3569
+ const existing = state.binding(root);
3570
+ const project = new ClientProjectState(state, root);
3571
+ if (existing && existing.serverUrl !== transport.url)
3572
+ throw new Error("This root is connected to another server.");
3573
+ if (existing && options.project && options.project !== existing.workspaceId)
3574
+ throw new Error("This root belongs to a different project. Use a different local root.");
3575
+ const info = await transport.serverInfo();
3576
+ if (!info.capabilities.includes("atomic-file-publications"))
3577
+ throw new Error(
3578
+ "Update the server before connecting: complete directory publications are required."
3579
+ );
3580
+ if (!info.capabilities.includes("multi-project"))
3581
+ throw new Error(
3582
+ "This client requires a multi-project server. Update the server before connecting."
3583
+ );
3584
+ let member = options.member;
3585
+ if (existing && !options.reconnect && !options.tokenFile) {
3586
+ if (member && member !== existing.memberId)
3587
+ throw new Error("This root belongs to a different member.");
3588
+ const client2 = new HubClient(existing.serverUrl, 1e4, existing);
3589
+ await client2.members();
3590
+ } else if (options.tokenFile) {
3591
+ const token = CredentialSchema.parse((await readFile5(options.tokenFile, "utf8")).trim());
3592
+ const deviceId = state.deviceFor(root);
3593
+ const client2 = new HubClient(options.url, 1e4, { token, deviceId });
3594
+ const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
3595
+ if (options.project && binding2.workspaceId !== options.project)
3596
+ throw new Error("Token belongs to a different project.");
3597
+ state.saveBinding(root, client2.url, token, binding2);
3598
+ } else {
3599
+ member ??= existing?.memberId;
3600
+ if (!member && process.stdin.isTTY) {
3601
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3602
+ try {
3603
+ member = (await rl.question("Member ID: ")).trim();
3604
+ } finally {
3605
+ rl.close();
3606
+ }
3607
+ }
3608
+ if (!member) throw new Error("--member is required when input is not interactive.");
3609
+ NameSchema.parse(member);
3610
+ if (existing && existing.memberId !== member)
3611
+ throw new Error("This root belongs to a different member.");
3612
+ const projectId = options.project ?? existing?.workspaceId;
3613
+ if (!projectId)
3614
+ throw new Error(
3615
+ "--project is required for a new connection; copy the command from the project settings."
3616
+ );
3617
+ NameSchema.parse(projectId);
3618
+ const request = project.enrollment(
3619
+ transport.url,
3620
+ member,
3621
+ projectId,
3622
+ Boolean(options.reconnect)
3623
+ );
3624
+ const binding2 = await transport.enroll(request);
3625
+ state.saveBinding(root, transport.url, request.credential, binding2);
3626
+ project.completeEnrollment();
3627
+ }
3628
+ const binding = state.binding(root);
3629
+ const client = new HubClient(binding.serverUrl, 1e4, binding);
3630
+ if (info.capabilities.includes("project-config")) {
3631
+ try {
3632
+ await applyProjectFile(project, await client.projectConfig());
3633
+ } catch (error) {
3634
+ if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
3635
+ throw error;
3636
+ }
2640
3637
  }
2641
- if (!loaded) await bootstrapLaunchAgent(definition.path);
2642
- await exec3("launchctl", ["kickstart", target]);
2643
- } else {
2644
- await exec3("systemctl", ["--user", "daemon-reload"]);
2645
- await exec3("systemctl", ["--user", "enable", definition.id]);
2646
- await exec3("systemctl", [
2647
- "--user",
2648
- old !== definition.content || alive?.version !== version ? "restart" : "start",
2649
- definition.id
2650
- ]);
2651
- }
2652
- const deadline = Date.now() + 15e3;
2653
- while (Date.now() < deadline) {
2654
- if ((await runningDaemon(directory))?.version === version) return;
2655
- await setTimeout3(150);
3638
+ const own = await metadata(root, binding.memberId);
3639
+ if (own && !own.directory) throw new Error("Member path is not a directory.");
3640
+ if (!own) await mkdir10(await safePath(root, binding.memberId));
3641
+ await ensureLocalConfig(root);
3642
+ const { token: _, ...visible } = binding;
3643
+ return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
3644
+ } finally {
3645
+ lock.close();
2656
3646
  }
2657
- throw new Error(
2658
- "Background service did not start. Run teammy logs and check the user service manager."
2659
- );
2660
- }
2661
- async function wakeDaemon(directory, version) {
2662
- await ensureDaemon(directory, version);
2663
- await daemonRequest(directory, { method: "wake" });
2664
3647
  }
2665
3648
 
2666
3649
  // apps/client/output.ts
@@ -2680,15 +3663,94 @@ function json(value) {
2680
3663
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
2681
3664
  }
2682
3665
 
3666
+ // apps/client/runtime/diagnostics.ts
3667
+ import { resolve as resolve9 } from "node:path";
3668
+ import { realpath as realpath7 } from "node:fs/promises";
3669
+ async function runDiagnostics(command, options, version) {
3670
+ const directory = await realpath7(options.directory).catch(() => resolve9(options.directory));
3671
+ const root = await realpath7(options.root).catch(() => resolve9(options.root));
3672
+ const log = new ClientLog(directory);
3673
+ if (command === "logs") {
3674
+ const entries = log.tail(options.all ? void 0 : root);
3675
+ if (options.json) json(entries);
3676
+ else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
3677
+ else
3678
+ for (const entry of entries)
3679
+ process.stdout.write(
3680
+ `${entry.at} ${entry.event}${entry.detail ? " " + entry.detail : ""}
3681
+ `
3682
+ );
3683
+ return 0;
3684
+ }
3685
+ const probe = await probeDaemon(directory);
3686
+ let state;
3687
+ let issue = probe.state === "unresponsive" || probe.state === "incompatible" ? probe.error : null;
3688
+ let current = null;
3689
+ let binding = null;
3690
+ let control = null;
3691
+ let maintenance = null;
3692
+ try {
3693
+ maintenance = readMaintenance(directory);
3694
+ } catch (e) {
3695
+ issue ??= diagnostic(e);
3696
+ }
3697
+ try {
3698
+ state = new ClientState(directory, { readOnly: true });
3699
+ const stored = state.binding(root);
3700
+ if (stored) {
3701
+ const { token: _, ...safe } = stored;
3702
+ binding = safe;
3703
+ const runtime = new RuntimeState(state);
3704
+ current = { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
3705
+ control = readControlStatus(state, root);
3706
+ if (probe.state === "ready") {
3707
+ try {
3708
+ current = await daemonRequest(directory, { method: "status", root }, 1e3);
3709
+ } catch (e) {
3710
+ issue ??= diagnostic(e);
3711
+ }
3712
+ }
3713
+ }
3714
+ } catch (e) {
3715
+ issue ??= diagnostic(e);
3716
+ } finally {
3717
+ state?.close();
3718
+ }
3719
+ if (options.json)
3720
+ json({
3721
+ ...current,
3722
+ binding,
3723
+ control,
3724
+ daemon: probe.state === "ready" ? probe.info : null,
3725
+ daemonState: probe.state,
3726
+ installedVersion: version,
3727
+ maintenance,
3728
+ issue,
3729
+ logPath: log.path
3730
+ });
3731
+ else {
3732
+ const title = maintenance ? "\u540E\u53F0\u540C\u6B65\u5DF2\u6682\u505C" : issue ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : !binding ? "\u5F53\u524D\u76EE\u5F55\u5C1A\u672A\u8FDE\u63A5" : !current?.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : current.status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : "\u540C\u6B65\u5DF2\u5F00\u542F";
3733
+ result(
3734
+ title,
3735
+ `\u5B89\u88C5 ${version} \xB7 \u8FD0\u884C ${probe.state === "ready" ? probe.info.version : "\u672A\u5C31\u7EEA"}`
3736
+ );
3737
+ if (issue || current?.status?.issue) result("\u5F85\u5904\u7406", issue ?? current.status.issue);
3738
+ if (current?.status?.lastSuccessAt)
3739
+ process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${current.status.lastSuccessAt}
3740
+ `);
3741
+ }
3742
+ return issue ? 1 : 0;
3743
+ }
3744
+
2683
3745
  // apps/client/client-command.ts
2684
3746
  var locationOptions = {
2685
- root: { type: "string", default: resolve6("workspaces") },
2686
- "state-dir": { type: "string", default: join12(homedir4(), ".teammy", "client") },
3747
+ root: { type: "string", default: resolve10("workspaces") },
3748
+ "state-dir": { type: "string", default: join18(homedir5(), ".teammy", "client") },
2687
3749
  json: { type: "boolean" },
2688
3750
  help: { type: "boolean", short: "h" }
2689
3751
  };
2690
3752
  async function runClientCommand(command, args, version) {
2691
- const { values } = parseArgs2({
3753
+ const { values } = parseArgs3({
2692
3754
  args,
2693
3755
  options: {
2694
3756
  ...locationOptions,
@@ -2708,54 +3770,16 @@ async function runClientCommand(command, args, version) {
2708
3770
  );
2709
3771
  return 0;
2710
3772
  }
3773
+ if (command === "logs" || command === "status")
3774
+ return runDiagnostics(
3775
+ command,
3776
+ { root: values.root, directory: values["state-dir"], json: values.json, all: values.all },
3777
+ version
3778
+ );
2711
3779
  const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
3780
+ await prepareClient(directory, version, { resume: !values["no-start"] });
2712
3781
  const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
2713
3782
  try {
2714
- if (command === "logs") {
2715
- const entries = log.tail(values.all ? void 0 : root);
2716
- if (values.json) json(entries);
2717
- else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
2718
- else
2719
- for (const item of entries)
2720
- process.stdout.write(
2721
- `${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
2722
- `
2723
- );
2724
- return 0;
2725
- }
2726
- if (command === "status") {
2727
- const binding = state.binding(root);
2728
- if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
2729
- const daemon = await runningDaemon(directory);
2730
- const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
2731
- const { token: _, ...publicBinding } = binding;
2732
- if (values.json)
2733
- json({
2734
- ...current,
2735
- binding: publicBinding,
2736
- control: readControlStatus(state, root),
2737
- daemon,
2738
- logPath: log.path
2739
- });
2740
- else {
2741
- const status = current.status;
2742
- 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";
2743
- result(
2744
- title,
2745
- new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
2746
- );
2747
- if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
2748
- else if (status?.lastSuccessAt)
2749
- process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
2750
- `);
2751
- if (status?.pendingFiles || status?.pendingDownloads)
2752
- process.stdout.write(
2753
- ` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
2754
- `
2755
- );
2756
- }
2757
- return 0;
2758
- }
2759
3783
  const existing = state.binding(root);
2760
3784
  const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
2761
3785
  if (!url) throw new Error("--url is required.");
@@ -2786,7 +3810,7 @@ async function runClientCommand(command, args, version) {
2786
3810
  tokenFile: values["token-file"],
2787
3811
  reconnect: values.reconnect
2788
3812
  });
2789
- const collection = await setupSessions(state, root, directory);
3813
+ const collection = await setupSessions(state, root, directory, { enable: true });
2790
3814
  for (const issue of collection.issues) log.write("sessions.setup.failed", root, issue);
2791
3815
  completePreparation(state, root);
2792
3816
  runtime.enable(root, !values["no-start"]);
@@ -2795,7 +3819,7 @@ async function runClientCommand(command, args, version) {
2795
3819
  if (values.json) json({ ...connected, collection, syncEnabled: !values["no-start"] });
2796
3820
  else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
2797
3821
  if (collection.issues.length && !values.json)
2798
- result("\u90E8\u5206\u4F1A\u8BDD Hook \u672A\u914D\u7F6E", "\u540E\u53F0\u53D1\u73B0\u4ECD\u53EF\u7528\uFF0C\u8BE6\u60C5\u89C1 teammy logs");
3822
+ result("\u90E8\u5206\u5BF9\u8BDD\u91C7\u96C6 Hook \u672A\u914D\u7F6E", "\u540E\u53F0\u53D1\u73B0\u4ECD\u53EF\u7528\uFF0C\u8BE6\u60C5\u89C1 teammy logs");
2799
3823
  return 0;
2800
3824
  } catch (e) {
2801
3825
  log.write(`${command}.failed`, root, diagnostic(e));
@@ -2806,16 +3830,16 @@ async function runClientCommand(command, args, version) {
2806
3830
  }
2807
3831
 
2808
3832
  // apps/client/files/synchronizer.ts
2809
- import { randomUUID as randomUUID6 } from "node:crypto";
3833
+ import { randomUUID as randomUUID7 } from "node:crypto";
2810
3834
  import { watch } from "node:fs";
2811
- import { lstat as lstat4, mkdir as mkdir10 } from "node:fs/promises";
2812
- import { join as join14, sep as sep4 } from "node:path";
3835
+ import { lstat as lstat4, mkdir as mkdir12 } from "node:fs/promises";
3836
+ import { join as join20, sep as sep5 } from "node:path";
2813
3837
  import { performance } from "node:perf_hooks";
2814
3838
 
2815
3839
  // packages/storage/local-blob-store.ts
2816
- import { createHash as createHash9, randomUUID as randomUUID5 } from "node:crypto";
2817
- import { link, mkdir as mkdir9, open as open4, readFile as readFile4, unlink as unlink3 } from "node:fs/promises";
2818
- import { dirname as dirname7, join as join13, resolve as resolve7 } from "node:path";
3840
+ import { createHash as createHash9, randomUUID as randomUUID6 } from "node:crypto";
3841
+ import { link, mkdir as mkdir11, open as open4, readFile as readFile6, unlink as unlink3 } from "node:fs/promises";
3842
+ import { dirname as dirname10, join as join19, resolve as resolve11 } from "node:path";
2819
3843
  function hash(content) {
2820
3844
  return createHash9("sha256").update(content).digest("hex");
2821
3845
  }
@@ -2833,14 +3857,14 @@ var BlobIntegrityError = class extends Error {
2833
3857
  var LocalBlobStore = class {
2834
3858
  root;
2835
3859
  constructor(root) {
2836
- this.root = resolve7(root);
3860
+ this.root = resolve11(root);
2837
3861
  }
2838
3862
  async put(content) {
2839
3863
  const bytes = Buffer.from(content);
2840
3864
  const sha256 = hash(bytes);
2841
3865
  const target = this.pathFor(sha256);
2842
- await mkdir9(dirname7(target), { recursive: true, mode: 448 });
2843
- const temporary = `${target}.${randomUUID5()}.tmp`;
3866
+ await mkdir11(dirname10(target), { recursive: true, mode: 448 });
3867
+ const temporary = `${target}.${randomUUID6()}.tmp`;
2844
3868
  const file = await open4(temporary, "wx", 384);
2845
3869
  try {
2846
3870
  try {
@@ -2856,7 +3880,7 @@ var LocalBlobStore = class {
2856
3880
  await this.get(sha256);
2857
3881
  }
2858
3882
  if (process.platform !== "win32") {
2859
- let directory = dirname7(target);
3883
+ let directory = dirname10(target);
2860
3884
  for (; ; ) {
2861
3885
  const handle = await open4(directory, "r");
2862
3886
  try {
@@ -2864,8 +3888,8 @@ var LocalBlobStore = class {
2864
3888
  } finally {
2865
3889
  await handle.close();
2866
3890
  }
2867
- if (directory === dirname7(this.root)) break;
2868
- directory = dirname7(directory);
3891
+ if (directory === dirname10(this.root)) break;
3892
+ directory = dirname10(directory);
2869
3893
  }
2870
3894
  }
2871
3895
  } finally {
@@ -2877,7 +3901,7 @@ var LocalBlobStore = class {
2877
3901
  const target = this.pathFor(sha256);
2878
3902
  let content;
2879
3903
  try {
2880
- content = await readFile4(target);
3904
+ content = await readFile6(target);
2881
3905
  } catch (error) {
2882
3906
  if (hasCode(error, "ENOENT")) return void 0;
2883
3907
  throw error;
@@ -2887,7 +3911,7 @@ var LocalBlobStore = class {
2887
3911
  }
2888
3912
  pathFor(sha256) {
2889
3913
  if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
2890
- return join13(this.root, "sha256", sha256.slice(0, 2), sha256);
3914
+ return join19(this.root, "sha256", sha256.slice(0, 2), sha256);
2891
3915
  }
2892
3916
  };
2893
3917
 
@@ -2905,15 +3929,18 @@ var PublicationQueue = class {
2905
3929
  const pending = this.state.pending(root);
2906
3930
  if (!pending || pending.retryAt > now) return false;
2907
3931
  try {
3932
+ let end = pending.nextIndex, bytes = 0;
3933
+ while (end < pending.batch.changes.length && end - pending.nextIndex < MAX_BATCH_FILES && bytes < 20 * 1024 * 1024) {
3934
+ const change = pending.batch.changes[end++];
3935
+ if (change.kind === "upsert") bytes += change.size;
3936
+ }
2908
3937
  const hashes = [
2909
3938
  ...new Set(
2910
- pending.batch.changes.flatMap(
2911
- (change) => change.kind === "upsert" ? [change.sha256] : []
2912
- )
3939
+ pending.batch.changes.slice(pending.nextIndex, end).flatMap((change) => change.kind === "upsert" ? [change.sha256] : [])
2913
3940
  )
2914
3941
  ];
2915
- const { missing: missing2 } = await this.transport.missingBlobs(hashes);
2916
- for (const hash2 of missing2) {
3942
+ const { missing: missing3 } = hashes.length ? await this.transport.missingBlobs(hashes) : { missing: [] };
3943
+ for (const hash2 of missing3) {
2917
3944
  if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
2918
3945
  const content = await this.blobs.get(hash2);
2919
3946
  if (!content) throw new Error("A queued content snapshot is missing from local storage.");
@@ -2921,9 +3948,12 @@ var PublicationQueue = class {
2921
3948
  if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
2922
3949
  throw new Error("Server acknowledged different content.");
2923
3950
  }
3951
+ this.state.uploaded(root, pending.batch.operationId, end);
3952
+ if (end < pending.batch.changes.length) return false;
2924
3953
  this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
2925
3954
  return true;
2926
3955
  } catch (error) {
3956
+ if (error instanceof Error && error.name === "AbortError") throw error;
2927
3957
  const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2928
3958
  this.state.postpone(
2929
3959
  root,
@@ -2947,17 +3977,17 @@ var FileSynchronizer = class _FileSynchronizer {
2947
3977
  if (!binding) throw new Error("Connect this root before synchronizing.");
2948
3978
  this.binding = binding;
2949
3979
  this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2950
- this.blobs = new LocalBlobStore(join14(stateDirectory, "blobs"));
3980
+ this.blobs = new LocalBlobStore(join20(stateDirectory, "blobs"));
2951
3981
  this.projectScope = options.scope === "project";
2952
3982
  this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
2953
3983
  this.publication = new FilePublicationState(state.db, options.scope);
2954
3984
  this.queue = new PublicationQueue(this.publication, this.blobs, {
2955
3985
  missingBlobs: (hashes) => this.client.missingBlobs(hashes),
2956
3986
  uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
2957
- commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, this.sharedConfig.revision) : this.client.commitFiles(batch)
3987
+ commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, batch.configRevision) : this.client.commitFiles(batch)
2958
3988
  });
2959
3989
  this.index = new SyncState(state, root, options.scope);
2960
- this.ownRoot = this.projectScope ? root : join14(root, binding.memberId);
3990
+ this.ownRoot = this.projectScope ? root : join20(root, binding.memberId);
2961
3991
  this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
2962
3992
  const started = performance.now();
2963
3993
  const epoch = Date.now();
@@ -2987,14 +4017,16 @@ var FileSynchronizer = class _FileSynchronizer {
2987
4017
  watchIssues = [];
2988
4018
  lastScan = -Infinity;
2989
4019
  busy = false;
4020
+ capable = false;
2990
4021
  stopped = false;
2991
4022
  abort = new AbortController();
2992
4023
  async checkCapabilities() {
2993
4024
  const info = await this.client.serverInfo();
2994
- if (!["identity", "blobs", "file-revisions"].every(
4025
+ if (!["identity", "blobs", "file-revisions", "atomic-file-publications"].every(
2995
4026
  (capability) => info.capabilities.includes(capability)
2996
4027
  ))
2997
- throw new Error("Server does not support file synchronization.");
4028
+ throw new Error("Upgrade the server to support complete directory publications.");
4029
+ this.capable = true;
2998
4030
  }
2999
4031
  setSettleMs(value) {
3000
4032
  if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
@@ -3022,6 +4054,7 @@ var FileSynchronizer = class _FileSynchronizer {
3022
4054
  };
3023
4055
  try {
3024
4056
  try {
4057
+ if (!this.capable) await this.checkCapabilities();
3025
4058
  if (this.projectScope) {
3026
4059
  try {
3027
4060
  this.sharedConfig = await this.client.sharedFilesConfig();
@@ -3081,11 +4114,10 @@ var FileSynchronizer = class _FileSynchronizer {
3081
4114
  }
3082
4115
  const blocked = await this.refreshDirty(report.issues);
3083
4116
  await this.audit(report.issues);
3084
- report.published = await this.queue.flush(this.root, this.clock());
3085
4117
  if (!this.publication.pending(this.root) && !this.stopped) {
3086
4118
  await this.stage(blocked, report.issues, settleMs);
3087
- report.published = await this.queue.flush(this.root, this.clock()) || report.published;
3088
4119
  }
4120
+ report.published = await this.queue.flush(this.root, this.clock());
3089
4121
  } catch (error) {
3090
4122
  report.issues.push(`publish: ${message(error)}`);
3091
4123
  }
@@ -3129,7 +4161,7 @@ var FileSynchronizer = class _FileSynchronizer {
3129
4161
  throw new Error("Manifest changed version during pagination.");
3130
4162
  revision = page.revision;
3131
4163
  files.push(...page.files);
3132
- if (page.nextCursor === null) return { revision, files };
4164
+ if (page.nextCursor === null) return { revision, complete: page.complete, files };
3133
4165
  if (page.nextCursor === after || files.length > 1e5)
3134
4166
  throw new Error("Manifest pagination did not make bounded progress.");
3135
4167
  after = page.nextCursor;
@@ -3156,10 +4188,12 @@ var FileSynchronizer = class _FileSynchronizer {
3156
4188
  }
3157
4189
  }
3158
4190
  const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
3159
- await mkdir10(ownerPath).catch((error) => {
4191
+ await mkdir12(ownerPath).catch((error) => {
3160
4192
  if (error.code !== "EEXIST") throw error;
3161
4193
  });
3162
- this.publication.initializePublication(this.root, manifest.revision, manifest.files);
4194
+ this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
4195
+ complete: manifest.complete
4196
+ });
3163
4197
  }
3164
4198
  listen() {
3165
4199
  if (this.options.watch === false || this.watcher || this.stopped) return;
@@ -3169,7 +4203,7 @@ var FileSynchronizer = class _FileSynchronizer {
3169
4203
  this.lastScan = -Infinity;
3170
4204
  return;
3171
4205
  }
3172
- const path = filename.toString().split(sep4).join("/");
4206
+ const path = filename.toString().split(sep5).join("/");
3173
4207
  if (!this.excludes(path)) this.index.event(path, this.clock());
3174
4208
  });
3175
4209
  this.watcher.on("error", (error) => {
@@ -3193,6 +4227,7 @@ var FileSynchronizer = class _FileSynchronizer {
3193
4227
  }
3194
4228
  }
3195
4229
  issues.push(...scan.issues);
4230
+ return scan.complete;
3196
4231
  }
3197
4232
  async refreshDirty(issues) {
3198
4233
  const blocked = /* @__PURE__ */ new Set();
@@ -3233,73 +4268,80 @@ var FileSynchronizer = class _FileSynchronizer {
3233
4268
  }
3234
4269
  }
3235
4270
  async stage(blocked, issues, settleMs) {
3236
- const ready = this.index.index(true).filter(
3237
- (row) => !this.excludes(row.path) && !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
3238
- );
3239
- if (!ready.length) return;
4271
+ const publication = this.publication.publication(this.root);
4272
+ if (publication.complete && !this.index.index(true).some((row) => !this.excludes(row.path)))
4273
+ return;
4274
+ if (!await this.reconcile("", issues)) return;
4275
+ const before = this.index.index().filter((row) => !this.excludes(row.path));
4276
+ const ready = before.filter((row) => row.dirty);
4277
+ if (ready.some(
4278
+ (row) => blocked.has(row.path) || row.eventPending || this.clock() - row.observedAt < settleMs
4279
+ ))
4280
+ return;
3240
4281
  const baseline = this.publication.ownerFiles(this.root);
3241
4282
  const planned = new Map(baseline);
3242
4283
  const changes = [];
3243
4284
  const snapshots = [];
3244
- let bytes = 0;
3245
4285
  ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
3246
4286
  for (const row of ready) {
3247
- if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
4287
+ if (this.stopped) return;
3248
4288
  try {
3249
4289
  if (row.signature === null) {
3250
- const current = await metadata(this.ownRoot, row.path);
3251
- if (current && !current.directory) {
3252
- this.index.event(row.path, this.clock());
3253
- continue;
3254
- }
3255
4290
  if (baseline.has(row.path) && row.seenLocal) {
3256
4291
  changes.push({ kind: "delete", path: row.path });
3257
4292
  planned.delete(row.path);
3258
- } else {
3259
- this.index.clean(row.path, row.generation, null, this.clock());
3260
- continue;
3261
4293
  }
3262
4294
  snapshots.push({ path: row.path, generation: row.generation, hash: null });
3263
4295
  continue;
3264
4296
  }
3265
- const key = row.path.toLowerCase();
3266
- const collision = [...planned.keys()].some(
3267
- (path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
3268
- );
3269
- if (collision) {
3270
- issues.push(
3271
- `${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
3272
- );
3273
- continue;
3274
- }
3275
4297
  const local = await readStable(this.ownRoot, row.path);
3276
- bytes += local.bytes.length;
3277
4298
  if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
3278
4299
  this.index.event(row.path, this.clock());
3279
- continue;
4300
+ return;
3280
4301
  }
3281
- if (baseline.get(row.path)?.sha256 === local.sha256) {
3282
- this.index.clean(row.path, row.generation, local.sha256, this.clock());
3283
- continue;
4302
+ if (baseline.get(row.path)?.sha256 !== local.sha256) {
4303
+ const ref = await this.blobs.put(local.bytes);
4304
+ changes.push({ kind: "upsert", path: row.path, ...ref });
4305
+ planned.set(row.path, ref);
3284
4306
  }
3285
- const ref = await this.blobs.put(local.bytes);
3286
- changes.push({ kind: "upsert", path: row.path, ...ref });
3287
- planned.set(row.path, ref);
3288
4307
  snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
3289
4308
  } catch (error) {
3290
4309
  this.index.event(row.path, this.clock());
3291
4310
  issues.push(`${row.path}: ${message(error)}`);
4311
+ return;
3292
4312
  }
3293
4313
  }
3294
- if (!changes.length) return;
3295
- const rootStat = await lstat4(this.ownRoot);
3296
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
3297
- throw new Error("Author directory disappeared before publication.");
3298
- this.publication.enqueue(this.root, {
3299
- operationId: randomUUID6(),
3300
- parentRevision: this.publication.publication(this.root).revision,
3301
- changes
3302
- });
4314
+ const keys = /* @__PURE__ */ new Set();
4315
+ for (const path of planned.keys()) {
4316
+ const key = path.toLowerCase();
4317
+ if (keys.has(key)) {
4318
+ issues.push(`${path}: conflicts with a preserved server path.`);
4319
+ return;
4320
+ }
4321
+ keys.add(key);
4322
+ }
4323
+ for (const key of keys) {
4324
+ const parts = key.split("/");
4325
+ for (let i = 1; i < parts.length; i++) {
4326
+ if (keys.has(parts.slice(0, i).join("/"))) {
4327
+ issues.push(`${key}: a file conflicts with its parent directory.`);
4328
+ return;
4329
+ }
4330
+ }
4331
+ }
4332
+ if (!await this.reconcile("", issues)) return;
4333
+ const after = this.index.index().filter((row) => !this.excludes(row.path));
4334
+ const generations = new Map(before.map((row) => [row.path, row.generation]));
4335
+ if (after.length !== before.length || after.some((row) => row.eventPending || generations.get(row.path) !== row.generation))
4336
+ return;
4337
+ if (changes.length || !publication.complete) {
4338
+ this.publication.enqueue(this.root, {
4339
+ operationId: randomUUID7(),
4340
+ parentRevision: publication.revision,
4341
+ changes,
4342
+ ...this.projectScope ? { configRevision: this.sharedConfig.revision } : {}
4343
+ });
4344
+ }
3303
4345
  for (const snapshot of snapshots)
3304
4346
  this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
3305
4347
  }
@@ -3342,7 +4384,7 @@ var FileSynchronizer = class _FileSynchronizer {
3342
4384
  }
3343
4385
  const job = this.index.job(owner);
3344
4386
  const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
3345
- await mkdir10(ownerRoot).catch((error) => {
4387
+ await mkdir12(ownerRoot).catch((error) => {
3346
4388
  if (error.code !== "EEXIST") throw error;
3347
4389
  });
3348
4390
  const ownerStat = await lstat4(ownerRoot);
@@ -3397,8 +4439,9 @@ function message(error) {
3397
4439
  }
3398
4440
 
3399
4441
  // apps/client/sessions/synchronizer.ts
3400
- import { randomUUID as randomUUID7 } from "node:crypto";
3401
- import { join as join17 } from "node:path";
4442
+ import { randomUUID as randomUUID8 } from "node:crypto";
4443
+ import { join as join23 } from "node:path";
4444
+ import { performance as performance3 } from "node:perf_hooks";
3402
4445
 
3403
4446
  // packages/protocol/session-key.ts
3404
4447
  import { createHash as createHash10 } from "node:crypto";
@@ -3408,15 +4451,15 @@ function sessionKey(agent, nativeId) {
3408
4451
 
3409
4452
  // apps/client/sessions/source.ts
3410
4453
  import { createReadStream, realpathSync } from "node:fs";
3411
- import { stat, realpath as realpath5, readdir as readdir2 } from "node:fs/promises";
3412
- import { basename as basename3, dirname as dirname9, relative as relative4, isAbsolute as isAbsolute4, sep as sep5, join as join15 } from "node:path";
4454
+ import { stat, realpath as realpath8, readdir as readdir2 } from "node:fs/promises";
4455
+ import { basename as basename4, dirname as dirname12, relative as relative5, isAbsolute as isAbsolute5, sep as sep6, join as join21 } from "node:path";
3413
4456
  import { createInterface as createInterface3 } from "node:readline";
3414
4457
  import * as zlib from "node:zlib";
3415
4458
 
3416
4459
  // apps/client/sessions/cursor.ts
3417
4460
  import { createHash as createHash11 } from "node:crypto";
3418
- import { basename as basename2, dirname as dirname8, resolve as resolve8 } from "node:path";
3419
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
4461
+ import { basename as basename3, dirname as dirname11, resolve as resolve12 } from "node:path";
4462
+ import { DatabaseSync as DatabaseSync6 } from "node:sqlite";
3420
4463
  import { fileURLToPath } from "node:url";
3421
4464
  function fields(bytes) {
3422
4465
  const data = Buffer.from(bytes);
@@ -3454,33 +4497,30 @@ function fields(bytes) {
3454
4497
  return result2;
3455
4498
  }
3456
4499
  function readCursor(path, projectDir, checkCwd) {
3457
- const db = new DatabaseSync2(path, { readOnly: true });
4500
+ const db = new DatabaseSync6(path, { readOnly: true });
3458
4501
  try {
3459
4502
  db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
3460
4503
  if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
3461
4504
  throw new Error("Unsupported Cursor CLI store version (expected 1).");
3462
- const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
4505
+ const row = db.prepare("SELECT value FROM meta WHERE key='0'").get();
3463
4506
  const hex = row?.value;
3464
4507
  if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
3465
4508
  throw new Error("Invalid Cursor metadata.");
3466
4509
  const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
3467
- if (typeof meta.agentId !== "string" || meta.agentId !== basename2(dirname8(path)) || meta.subagentInfo != null)
4510
+ if (typeof meta.agentId !== "string" || meta.agentId !== basename3(dirname11(path)) || meta.subagentInfo != null)
3468
4511
  throw new Error("Invalid or delegated Cursor session identity.");
3469
- let budget = 64 * 1024 * 1024;
3470
4512
  const blob = (id) => {
3471
4513
  if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
3472
- const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
3473
- if (!(value instanceof Uint8Array))
3474
- throw new Error("Cursor history missing or exceeds 64 MiB.");
4514
+ const value = db.prepare("SELECT data FROM blobs WHERE id=?").get(id)?.data;
4515
+ if (!(value instanceof Uint8Array)) throw new Error("Cursor history is missing.");
3475
4516
  const bytes = Buffer.from(value);
3476
- budget -= bytes.length;
3477
4517
  if (createHash11("sha256").update(bytes).digest("hex") !== id)
3478
4518
  throw new Error("Cursor blob failed integrity verification.");
3479
4519
  return bytes;
3480
4520
  };
3481
4521
  const root = fields(blob(String(meta.latestRootBlobId)));
3482
- const bucket = basename2(dirname8(dirname8(path)));
3483
- const md5 = (cwd2) => createHash11("md5").update(resolve8(cwd2)).digest("hex");
4522
+ const bucket = basename3(dirname11(dirname11(path)));
4523
+ const md5 = (cwd2) => createHash11("md5").update(resolve12(cwd2)).digest("hex");
3484
4524
  let cwd = md5(projectDir) === bucket ? projectDir : void 0;
3485
4525
  for (const uri of root.get(9) ?? []) {
3486
4526
  try {
@@ -3496,9 +4536,8 @@ function readCursor(path, projectDir, checkCwd) {
3496
4536
  checkCwd?.(cwd);
3497
4537
  const refs = [];
3498
4538
  for (const archive of root.get(13) ?? root.get(11) ?? [])
3499
- refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
3500
- refs.push(...root.get(1) ?? []);
3501
- if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
4539
+ for (const ref of fields(blob(archive.toString("hex"))).get(1) ?? []) refs.push(ref);
4540
+ for (const ref of root.get(1) ?? []) refs.push(ref);
3502
4541
  const messages = [];
3503
4542
  const counts = /* @__PURE__ */ new Map();
3504
4543
  for (const ref of refs) {
@@ -3541,11 +4580,11 @@ function readCursor(path, projectDir, checkCwd) {
3541
4580
 
3542
4581
  // apps/client/sessions/source.ts
3543
4582
  function within(project, cwd) {
3544
- const rel = relative4(project, cwd);
3545
- return rel !== ".." && !rel.startsWith(`..${sep5}`) && !isAbsolute4(rel);
4583
+ const rel = relative5(project, cwd);
4584
+ return rel !== ".." && !rel.startsWith(`..${sep6}`) && !isAbsolute5(rel);
3546
4585
  }
3547
4586
  var cursorEncoded = (cwd) => cwd.replace(/^[/\\]+/, "").replace(/[/\\]/g, "-");
3548
- var cursorDatabase = (path) => basename3(path) === "store.db";
4587
+ var cursorDatabase = (path) => basename4(path) === "store.db";
3549
4588
  async function sourceSignature(path, agent) {
3550
4589
  const paths = agent === "cursor" && cursorDatabase(path) ? [path, `${path}-wal`] : [path];
3551
4590
  const signatures = [];
@@ -3569,12 +4608,12 @@ async function sourceSignature(path, agent) {
3569
4608
  return JSON.stringify(signatures);
3570
4609
  }
3571
4610
  async function cursorScope(path, project) {
3572
- let container = dirname9(path);
3573
- while (basename3(container) !== "agent-transcripts" && dirname9(container) !== container)
3574
- container = dirname9(container);
3575
- if (basename3(container) !== "agent-transcripts")
4611
+ let container = dirname12(path);
4612
+ while (basename4(container) !== "agent-transcripts" && dirname12(container) !== container)
4613
+ container = dirname12(container);
4614
+ if (basename4(container) !== "agent-transcripts")
3576
4615
  throw new Error("Cursor transcript workspace cannot be verified.");
3577
- const encoded = basename3(dirname9(container));
4616
+ const encoded = basename4(dirname12(container));
3578
4617
  const matches = [];
3579
4618
  const visit = async (directory) => {
3580
4619
  const prefix = cursorEncoded(directory);
@@ -3584,7 +4623,7 @@ async function cursorScope(path, project) {
3584
4623
  }
3585
4624
  if (!encoded.startsWith(prefix + "-")) return;
3586
4625
  for (const entry of await readdir2(directory, { withFileTypes: true })) {
3587
- if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(join15(directory, entry.name));
4626
+ if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(join21(directory, entry.name));
3588
4627
  }
3589
4628
  };
3590
4629
  await visit(project);
@@ -3594,7 +4633,7 @@ async function cursorScope(path, project) {
3594
4633
  }
3595
4634
  async function readSource(agent, path, projectDir) {
3596
4635
  const signature2 = await sourceSignature(path, agent);
3597
- const project = await realpath5(projectDir);
4636
+ const project = await realpath8(projectDir);
3598
4637
  if (agent === "cursor" && cursorDatabase(path)) {
3599
4638
  const snapshot2 = readCursor(path, projectDir, (cwd) => {
3600
4639
  if (!within(project, realpathSync(cwd)))
@@ -3602,7 +4641,7 @@ async function readSource(agent, path, projectDir) {
3602
4641
  });
3603
4642
  return { snapshot: snapshot2, incomplete: false, skipped: 0, signature: signature2 };
3604
4643
  }
3605
- const cursor = agent === "cursor" ? { nativeId: basename3(path, ".jsonl"), cwd: await cursorScope(path, projectDir) } : void 0;
4644
+ const cursor = agent === "cursor" ? { nativeId: basename4(path, ".jsonl"), cwd: await cursorScope(path, projectDir) } : void 0;
3606
4645
  const normalizer = new JsonlNormalizer(agent, cursor);
3607
4646
  const stream = createReadStream(path, {
3608
4647
  end: Math.max(0, Number(JSON.parse(signature2)[0][2]) - 1)
@@ -3633,7 +4672,7 @@ async function readSource(agent, path, projectDir) {
3633
4672
  }
3634
4673
  const cwd = row.cwd ?? (row.type === "session_meta" ? object(row.payload).cwd : void 0);
3635
4674
  if (typeof cwd === "string" && !checkedCwds.has(cwd)) {
3636
- const actual = await realpath5(cwd).catch((error) => {
4675
+ const actual = await realpath8(cwd).catch((error) => {
3637
4676
  if (error.code === "ENOENT" && !within(project, cwd))
3638
4677
  throw new Error("Session cwd is outside the registered project.");
3639
4678
  throw error;
@@ -3654,21 +4693,54 @@ async function readSource(agent, path, projectDir) {
3654
4693
  if (before[0] !== after[0] || before[1] !== after[1] || Number(after[2]) < Number(before[2]) || before[2] === after[2] && before.join() !== after.join())
3655
4694
  throw new Error("Session source changed while reading; retry next cycle.");
3656
4695
  const snapshot = normalizer.finish();
3657
- if (!within(project, await realpath5(snapshot.cwd)))
4696
+ if (!within(project, await realpath8(snapshot.cwd)))
3658
4697
  throw new Error("Session cwd is outside the registered project.");
3659
4698
  return { snapshot, incomplete: lastBad, skipped, signature: signature2 };
3660
4699
  }
3661
4700
 
3662
4701
  // apps/client/sessions/discovery.ts
3663
4702
  import { opendir as opendir2 } from "node:fs/promises";
3664
- import { join as join16 } from "node:path";
4703
+ import { performance as performance2 } from "node:perf_hooks";
4704
+ import { join as join22 } from "node:path";
4705
+
4706
+ // apps/client/sessions/work-queue.ts
4707
+ var SessionWorkQueue = class {
4708
+ active = 0;
4709
+ waiting = [];
4710
+ async run(action) {
4711
+ if (this.active >= 2) await new Promise((resolve16) => this.waiting.push(resolve16));
4712
+ else this.active++;
4713
+ try {
4714
+ return await action();
4715
+ } finally {
4716
+ const next = this.waiting.shift();
4717
+ if (next) next();
4718
+ else this.active--;
4719
+ }
4720
+ }
4721
+ };
4722
+ var sessionWorkQueue = new SessionWorkQueue();
4723
+ var publications = /* @__PURE__ */ new Map();
4724
+ async function serializeSession(identity, action) {
4725
+ const previous = publications.get(identity) ?? Promise.resolve();
4726
+ const pending = previous.catch(() => {
4727
+ }).then(action);
4728
+ publications.set(identity, pending);
4729
+ try {
4730
+ return await pending;
4731
+ } finally {
4732
+ if (publications.get(identity) === pending) publications.delete(identity);
4733
+ }
4734
+ }
4735
+
4736
+ // apps/client/sessions/discovery.ts
3665
4737
  async function* candidates(directory, agent) {
3666
4738
  const queue = [directory];
3667
4739
  while (queue.length) {
3668
4740
  const current = queue.shift();
3669
4741
  const dir = await opendir2(current);
3670
4742
  for await (const entry of dir) {
3671
- const path = join16(current, entry.name);
4743
+ const path = join22(current, entry.name);
3672
4744
  if (entry.isDirectory()) queue.push(path);
3673
4745
  if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" || entry.name.endsWith(".jsonl") : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
3674
4746
  yield path;
@@ -3682,10 +4754,12 @@ var SessionDiscovery = class {
3682
4754
  }
3683
4755
  state;
3684
4756
  scans = /* @__PURE__ */ new Map();
4757
+ pending = false;
3685
4758
  async cycle() {
4759
+ this.pending = false;
3686
4760
  const issues = [];
3687
4761
  let entries = 500;
3688
- let reads = 5;
4762
+ const started = performance2.now();
3689
4763
  const configs = this.state.directories();
3690
4764
  for (const [directory, scan] of this.scans) {
3691
4765
  if (!configs.some((config) => config.directory === directory)) {
@@ -3701,7 +4775,7 @@ var SessionDiscovery = class {
3701
4775
  }
3702
4776
  if (scan.nextAt > Date.now()) continue;
3703
4777
  try {
3704
- while (entries > 0 && reads > 0) {
4778
+ while (entries > 0 && performance2.now() - started < 1e3) {
3705
4779
  entries--;
3706
4780
  const entry = await scan.iterator.next();
3707
4781
  if (entry.done) {
@@ -3714,13 +4788,14 @@ var SessionDiscovery = class {
3714
4788
  const signature2 = await sourceSignature(path, config.agent);
3715
4789
  const db = this.state.state.db;
3716
4790
  if (db.prepare(
3717
- "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
3718
- ).get(this.state.root, path, signature2))
4791
+ "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=? AND parser_version=?"
4792
+ ).get(this.state.root, path, signature2, parserVersions[config.agent]))
3719
4793
  continue;
3720
4794
  try {
3721
- const result2 = await readSource(config.agent, path, config.projectDir);
4795
+ const result2 = await sessionWorkQueue.run(
4796
+ () => readSource(config.agent, path, config.projectDir)
4797
+ );
3722
4798
  if (!result2.snapshot.messages.length) continue;
3723
- reads--;
3724
4799
  this.state.track(config.agent, path, config.projectDir);
3725
4800
  } catch (error) {
3726
4801
  const message2 = error instanceof Error ? error.message : "Discovery failed.";
@@ -3730,8 +4805,8 @@ var SessionDiscovery = class {
3730
4805
  continue;
3731
4806
  }
3732
4807
  db.prepare(
3733
- "INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
3734
- ).run(this.state.root, path, signature2);
4808
+ "INSERT INTO session_discovery_cache(root,path,signature,parser_version) VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature,parser_version=excluded.parser_version"
4809
+ ).run(this.state.root, path, signature2, parserVersions[config.agent]);
3735
4810
  }
3736
4811
  } catch (error) {
3737
4812
  if (error.code !== "ENOENT")
@@ -3743,6 +4818,7 @@ var SessionDiscovery = class {
3743
4818
  scan.nextAt = Date.now() + 3e5;
3744
4819
  }
3745
4820
  }
4821
+ this.pending = [...this.scans.values()].some((scan) => scan.nextAt <= Date.now());
3746
4822
  return issues;
3747
4823
  }
3748
4824
  async refresh() {
@@ -3758,10 +4834,11 @@ var SessionSynchronizer = class {
3758
4834
  constructor(state, root, directory, signal) {
3759
4835
  this.root = root;
3760
4836
  this.directory = directory;
4837
+ this.signal = signal;
3761
4838
  const binding = state.binding(root);
3762
4839
  if (!binding) throw new Error("Connect this root before collecting sessions.");
3763
4840
  this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
3764
- this.blobs = new LocalBlobStore(join17(directory, "blobs"));
4841
+ this.blobs = new LocalBlobStore(join23(directory, "blobs"));
3765
4842
  this.index = new SessionState(state, root);
3766
4843
  this.discovery = new SessionDiscovery(this.index);
3767
4844
  }
@@ -3772,8 +4849,16 @@ var SessionSynchronizer = class {
3772
4849
  blobs;
3773
4850
  discovery;
3774
4851
  capable = false;
4852
+ passAt;
4853
+ signal;
3775
4854
  async cycle() {
3776
- const report = { published: 0, checked: 0, issues: [], warnings: [] };
4855
+ const report = {
4856
+ published: 0,
4857
+ checked: 0,
4858
+ pending: false,
4859
+ issues: [],
4860
+ warnings: []
4861
+ };
3777
4862
  report.issues.push(
3778
4863
  ...await consumeHints(
3779
4864
  this.directory,
@@ -3783,7 +4868,10 @@ var SessionSynchronizer = class {
3783
4868
  )
3784
4869
  );
3785
4870
  report.warnings.push(...await this.discovery.cycle());
3786
- const sources = this.index.sources(void 0, 200);
4871
+ this.passAt ??= Date.now();
4872
+ const candidates2 = this.index.sources(void 0, 201, this.passAt);
4873
+ const sources = candidates2.slice(0, 200);
4874
+ report.pending = candidates2.length > sources.length || this.discovery.pending;
3787
4875
  if (sources.length && !this.capable) {
3788
4876
  const info = await this.client.serverInfo();
3789
4877
  if (!["blobs", "session-revisions"].every(
@@ -3792,60 +4880,86 @@ var SessionSynchronizer = class {
3792
4880
  throw new Error("Server does not support session publication. Upgrade the server first.");
3793
4881
  this.capable = true;
3794
4882
  }
3795
- let reads = 5;
3796
- let uploads = 5;
3797
- for (const scheduled of sources) {
3798
- let source = this.index.get(scheduled.path);
3799
- if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
3800
- report.checked++;
3801
- this.index.checked(source.path, source.lastError);
3802
- try {
3803
- if (source.pending) {
3804
- if (source.pendingPath !== source.path) continue;
3805
- if (source.retryAt > Date.now()) {
3806
- if (source.lastError)
3807
- report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
3808
- continue;
3809
- }
3810
- uploads--;
3811
- await this.flush(source);
3812
- report.published++;
3813
- continue;
3814
- }
3815
- const signature2 = await sourceSignature(source.path, source.agent);
3816
- if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
3817
- this.index.checked(source.path);
3818
- continue;
4883
+ let offset = 0;
4884
+ let handled = 0;
4885
+ const started = performance3.now();
4886
+ await Promise.all(
4887
+ Array.from({ length: 2 }, async () => {
4888
+ for (; ; ) {
4889
+ if (performance3.now() - started >= 1e3) break;
4890
+ const source = sources[offset++];
4891
+ if (!source || this.signal?.aborted) break;
4892
+ await sessionWorkQueue.run(() => this.collect(source.path, report));
4893
+ handled++;
3819
4894
  }
3820
- reads--;
3821
- const observed = await readSource(source.agent, source.path, source.projectDir);
3822
- if (!observed.snapshot.messages.length) {
3823
- this.index.checked(source.path);
3824
- continue;
4895
+ })
4896
+ );
4897
+ report.pending ||= handled < candidates2.length;
4898
+ if (handled >= candidates2.length) this.passAt = void 0;
4899
+ return report;
4900
+ }
4901
+ lock(key, action) {
4902
+ return serializeSession(JSON.stringify([this.client.url, this.root, key]), action);
4903
+ }
4904
+ async collect(path, report) {
4905
+ if (this.signal?.aborted) return;
4906
+ let source = this.index.get(path);
4907
+ report.checked++;
4908
+ this.index.checked(source.path, source.lastError);
4909
+ try {
4910
+ if (source.pending) {
4911
+ if (source.pendingPath !== source.path) return;
4912
+ if (source.retryAt > Date.now()) {
4913
+ if (source.lastError)
4914
+ report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
4915
+ return;
3825
4916
  }
3826
- if (observed.skipped)
4917
+ await this.lock(source.key, async () => {
4918
+ const current = this.index.get(source.path);
4919
+ if (!current.pending || current.pendingPath !== current.path || current.retryAt > Date.now())
4920
+ return;
4921
+ await this.flush(current);
4922
+ report.published++;
4923
+ });
4924
+ return;
4925
+ }
4926
+ const signature2 = await sourceSignature(source.path, source.agent);
4927
+ if (source.parserVersion === parserVersions[source.agent] && signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
4928
+ this.index.checked(source.path);
4929
+ return;
4930
+ }
4931
+ const observed = await readSource(source.agent, source.path, source.projectDir);
4932
+ if (!observed.snapshot.messages.length) {
4933
+ this.index.checked(source.path);
4934
+ return;
4935
+ }
4936
+ if (observed.skipped)
4937
+ report.warnings.push(
4938
+ `${source.path}: skipped ${observed.skipped} unreadable record(s); retained complete dialogue.`
4939
+ );
4940
+ if (observed.skipped && source.hash) {
4941
+ const previous = await this.blobs.get(source.hash);
4942
+ if (previous && JSON.parse(Buffer.from(previous).toString("utf8")).messages.length > observed.snapshot.messages.length) {
4943
+ this.index.observed(source.path, observed.signature);
3827
4944
  report.warnings.push(
3828
- `${source.path}: skipped ${observed.skipped} unreadable record(s); retained complete dialogue.`
4945
+ `${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
3829
4946
  );
3830
- if (observed.skipped && source.hash) {
3831
- const previous = await this.blobs.get(source.hash);
3832
- if (previous && JSON.parse(Buffer.from(previous).toString("utf8")).messages.length > observed.snapshot.messages.length) {
3833
- this.index.observed(source.path, observed.signature);
3834
- report.warnings.push(
3835
- `${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
3836
- );
3837
- continue;
3838
- }
4947
+ return;
3839
4948
  }
3840
- const key = sessionKey(source.agent, observed.snapshot.nativeId);
3841
- if (source.key && source.key !== key)
3842
- throw new Error(
3843
- "Native session identity changed at this path; register the new source explicitly."
3844
- );
3845
- const bytes = Buffer.from(JSON.stringify(observed.snapshot));
3846
- if (bytes.length > MAX_BLOB_BYTES)
3847
- throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
3848
- const blob = await this.blobs.put(bytes);
4949
+ }
4950
+ const key = sessionKey(source.agent, observed.snapshot.nativeId);
4951
+ if (source.key && source.key !== key)
4952
+ throw new Error(
4953
+ "Native session identity changed at this path; register the new source explicitly."
4954
+ );
4955
+ const bytes = Buffer.from(JSON.stringify(observed.snapshot));
4956
+ if (bytes.length > MAX_BLOB_BYTES)
4957
+ throw new Error(
4958
+ `Normalized session exceeds the ${MAX_BLOB_BYTES / 1024 / 1024} MiB snapshot limit.`
4959
+ );
4960
+ const blob = await this.blobs.put(bytes);
4961
+ await this.lock(key, async () => {
4962
+ source = this.index.get(source.path);
3849
4963
  let revision = source.revision;
3850
4964
  if (!source.key && !this.index.publication(key)) {
3851
4965
  try {
@@ -3864,38 +4978,37 @@ var SessionSynchronizer = class {
3864
4978
  this.index.baseline(source.path, key, revision);
3865
4979
  source = this.index.get(source.path);
3866
4980
  revision = source.revision;
3867
- if (source.pending) continue;
4981
+ if (source.pending) return;
3868
4982
  if (source.agent === "cursor" && source.selectedPath && cursorDatabase(source.selectedPath) && !cursorDatabase(source.path)) {
3869
4983
  this.index.observed(source.path, observed.signature);
3870
- continue;
4984
+ return;
3871
4985
  }
3872
4986
  if (blob.sha256 === source.hash) {
3873
4987
  this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
3874
- continue;
4988
+ return;
3875
4989
  }
3876
4990
  this.index.stage(
3877
4991
  source.path,
3878
- { operationId: randomUUID7(), sessionKey: key, parentRevision: revision, blob },
4992
+ { operationId: randomUUID8(), sessionKey: key, parentRevision: revision, blob },
3879
4993
  observed.signature
3880
4994
  );
3881
- uploads--;
3882
4995
  await this.flush(this.index.get(source.path));
3883
4996
  report.published++;
3884
- } catch (error) {
3885
- const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
3886
- this.index.checked(source.path, message2);
3887
- report.issues.push(`${source.path}: ${message2}`);
3888
- }
4997
+ });
4998
+ } catch (error) {
4999
+ if (this.signal?.aborted) return;
5000
+ const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
5001
+ this.index.checked(source.path, message2);
5002
+ report.issues.push(`${source.path}: ${message2}`);
3889
5003
  }
3890
- return report;
3891
5004
  }
3892
5005
  async flush(source) {
3893
5006
  const request = source.pending;
3894
5007
  try {
3895
- const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
3896
- if (missing2.some((hash2) => hash2 !== request.blob.sha256))
5008
+ const { missing: missing3 } = await this.client.missingBlobs([request.blob.sha256]);
5009
+ if (missing3.some((hash2) => hash2 !== request.blob.sha256))
3897
5010
  throw new Error("Server requested unrelated session content.");
3898
- if (missing2.length) {
5011
+ if (missing3.length) {
3899
5012
  const bytes = await this.blobs.get(request.blob.sha256);
3900
5013
  if (!bytes) throw new Error("Queued session snapshot is missing.");
3901
5014
  const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
@@ -3909,9 +5022,11 @@ var SessionSynchronizer = class {
3909
5022
  source.path,
3910
5023
  result2.revision,
3911
5024
  request.blob.sha256,
3912
- source.pendingSignature
5025
+ source.pendingSignature,
5026
+ source.pendingParserVersion
3913
5027
  );
3914
5028
  } catch (error) {
5029
+ if (this.signal?.aborted) throw error;
3915
5030
  const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
3916
5031
  this.index.postpone(
3917
5032
  source,
@@ -4027,7 +5142,13 @@ var SyncJob = class {
4027
5142
  pendingPublication: false,
4028
5143
  issues: []
4029
5144
  };
4030
- let sessions = { published: 0, checked: 0, issues: [], warnings: [] };
5145
+ let sessions = {
5146
+ published: 0,
5147
+ checked: 0,
5148
+ pending: false,
5149
+ issues: [],
5150
+ warnings: []
5151
+ };
4031
5152
  try {
4032
5153
  await verifyWorkspace(this.state, this.root);
4033
5154
  if (!this.controlStarted) {
@@ -4089,11 +5210,20 @@ var SyncJob = class {
4089
5210
  );
4090
5211
  }
4091
5212
  this.lastIssue = issueKey;
4092
- this.nextAt = now + (report.issues.length || this.control.enabled && (this.control.status.configError || this.control.status.reportError) ? Math.min(9e5, Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)) : pendingDownloads ? 250 : this.intervalMs);
5213
+ const uploadReady = [this.state, new FilePublicationState(this.state.db, "project")].some(
5214
+ (state) => {
5215
+ const pending = state.pending(this.root);
5216
+ return pending && pending.retryAt <= now;
5217
+ }
5218
+ );
5219
+ this.nextAt = now + (sessions.pending ? 1e3 : report.issues.length || this.control.enabled && (this.control.status.configError || this.control.status.reportError) ? Math.min(
5220
+ 9e5,
5221
+ Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)
5222
+ ) : pendingDownloads ? 250 : uploadReady ? 1e3 : this.intervalMs);
4093
5223
  this.runtime.save(this.root, {
4094
5224
  state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
4095
5225
  checkedAt,
4096
- lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
5226
+ lastSuccessAt: !issue && !pendingDownloads && !report.pendingPublication ? checkedAt : prior?.lastSuccessAt ?? null,
4097
5227
  pendingFiles,
4098
5228
  pendingDownloads,
4099
5229
  issue: issue || null
@@ -4139,7 +5269,7 @@ Use teammy status or teammy logs for diagnostics.
4139
5269
  `;
4140
5270
  async function runSyncCommand(args, version) {
4141
5271
  const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
4142
- const { values } = parseArgs3({
5272
+ const { values } = parseArgs4({
4143
5273
  args: action ? args.slice(1) : args,
4144
5274
  options: {
4145
5275
  ...locationOptions,
@@ -4179,16 +5309,29 @@ async function runSyncCommand(args, version) {
4179
5309
  "Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
4180
5310
  );
4181
5311
  const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
5312
+ await prepareClient(directory, version, {
5313
+ resume: action === "on",
5314
+ ...action ? { intent: { root, enabled: action === "on" } } : {}
5315
+ });
4182
5316
  const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
4183
5317
  try {
4184
5318
  if (!state.binding(root)) throw new Error("Connect this workspace first.");
4185
5319
  if (action) {
4186
5320
  if (action === "on") await verifyWorkspace(state, root);
4187
5321
  runtime.enable(root, action === "on");
4188
- if (action === "on") await wakeDaemon(directory, version);
4189
- else {
4190
- if (await runningDaemon(directory))
4191
- await daemonRequest(directory, { method: "wake" }, 45e3);
5322
+ if (action === "on") {
5323
+ const hooks = await setupSessions(state, root, directory);
5324
+ for (const issue of hooks.issues) log.write("sessions.setup.failed", root, issue);
5325
+ await wakeDaemon(directory, version);
5326
+ } else {
5327
+ try {
5328
+ if (await runningDaemon(directory))
5329
+ await daemonRequest(directory, { method: "wake" }, 45e3);
5330
+ } catch (error) {
5331
+ log.write("daemon.recovering", root, diagnostic(error));
5332
+ await stopDaemon(directory);
5333
+ if (runtime.roots().length) await wakeDaemon(directory, version);
5334
+ }
4192
5335
  const status = runtime.status(root);
4193
5336
  if (status) runtime.save(root, { ...status, state: "stopped" });
4194
5337
  log.write("sync.off_requested", root);
@@ -4249,10 +5392,60 @@ async function runSyncCommand(args, version) {
4249
5392
  }
4250
5393
  }
4251
5394
 
5395
+ // apps/client/maintenance-command.ts
5396
+ import { parseArgs as parseArgs5 } from "node:util";
5397
+ import { homedir as homedir6 } from "node:os";
5398
+ import { join as join24, resolve as resolve13 } from "node:path";
5399
+ import { mkdir as mkdir13, realpath as realpath9 } from "node:fs/promises";
5400
+ async function runMaintenanceCommand(command, args, version) {
5401
+ const { values } = parseArgs5({
5402
+ args,
5403
+ options: {
5404
+ "state-dir": { type: "string", default: join24(homedir6(), ".teammy", "client") },
5405
+ stop: { type: "boolean" },
5406
+ json: { type: "boolean" },
5407
+ help: { type: "boolean", short: "h" }
5408
+ }
5409
+ });
5410
+ if (values.help) {
5411
+ process.stdout.write(
5412
+ `Usage: teammy ${command}${command === "repair" ? " [--stop]" : ""} [--state-dir DIR] [--json]
5413
+
5414
+ ${command === "repair" ? "Repair this local client and preserve project on/off settings. --stop pauses the entire background service without opening the business database." : "Stop and unregister this local background service and remove its project hooks. Keeps workspaces, bindings and history; then run npm uninstall -g @teammy/cli."}
5415
+ `
5416
+ );
5417
+ return 0;
5418
+ }
5419
+ if (command === "uninstall" && values.stop) throw new Error("--stop is a repair option.");
5420
+ await mkdir13(resolve13(values["state-dir"]), { recursive: true, mode: 448 });
5421
+ const directory = await realpath9(values["state-dir"]);
5422
+ const report = await prepareClient(directory, version, {
5423
+ force: true,
5424
+ resume: true,
5425
+ stop: Boolean(values.stop),
5426
+ uninstall: command === "uninstall"
5427
+ });
5428
+ if (values.json) json(report);
5429
+ else {
5430
+ result(
5431
+ command === "uninstall" ? "\u672C\u673A\u63A5\u5165\u5DF2\u5378\u8F7D" : values.stop ? "\u540E\u53F0\u540C\u6B65\u5DF2\u6682\u505C" : "\u5BA2\u6237\u7AEF\u5DF2\u5C31\u7EEA",
5432
+ version
5433
+ );
5434
+ if (!values.stop && command !== "uninstall")
5435
+ result(`\u5DF2\u6062\u590D ${report.enabled} \u4E2A\u9879\u76EE\uFF1B${report.stopped} \u4E2A\u9879\u76EE\u4FDD\u6301\u505C\u6B62`);
5436
+ if (report.issues.length) result(`${report.issues.length} \u9879\u914D\u7F6E\u9700\u8981\u5904\u7406`, "teammy logs --all");
5437
+ if (command === "uninstall") result("\u5B89\u88C5\u5305\u53EF\u79FB\u9664", "npm uninstall -g @teammy/cli");
5438
+ }
5439
+ return report.issues.length ? 1 : 0;
5440
+ }
5441
+
4252
5442
  // apps/client/runtime/daemon.ts
4253
5443
  import { createServer } from "node:net";
4254
5444
  import { chmod as chmod2, unlink as unlink4 } from "node:fs/promises";
5445
+ import { join as join25 } from "node:path";
5446
+ import { resolve as resolve14 } from "node:path";
4255
5447
  async function runDaemon(directory, version) {
5448
+ await prepareClient(directory, version, { daemonStartup: true });
4256
5449
  const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
4257
5450
  const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
4258
5451
  let closing = false, serial = Promise.resolve();
@@ -4317,7 +5510,15 @@ async function runDaemon(directory, version) {
4317
5510
  void (async () => {
4318
5511
  const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
4319
5512
  if (closing) throw new Error("Background service is stopping.");
4320
- if (input.method === "ping") return { pid: process.pid, version };
5513
+ if (input.method === "ping")
5514
+ return {
5515
+ pid: process.pid,
5516
+ version,
5517
+ protocol: CONTROL_PROTOCOL,
5518
+ executable: process.execPath,
5519
+ entry: resolve14(process.argv[1]),
5520
+ schema: clientSchemaVersion
5521
+ };
4321
5522
  if (input.method === "wake") {
4322
5523
  retry.clear();
4323
5524
  await reconcile();
@@ -4355,8 +5556,8 @@ async function runDaemon(directory, version) {
4355
5556
  let timer;
4356
5557
  let resolveStop = () => {
4357
5558
  };
4358
- const stopped = new Promise((resolve10) => {
4359
- resolveStop = resolve10;
5559
+ const stopped = new Promise((resolve16) => {
5560
+ resolveStop = resolve16;
4360
5561
  });
4361
5562
  const stop = () => {
4362
5563
  closing = true;
@@ -4367,14 +5568,15 @@ async function runDaemon(directory, version) {
4367
5568
  await unlink4(socketPath(directory)).catch((e) => {
4368
5569
  if (e.code !== "ENOENT") throw e;
4369
5570
  });
4370
- await new Promise((resolve10, reject) => {
5571
+ await new Promise((resolve16, reject) => {
4371
5572
  server.once("error", reject);
4372
5573
  server.listen(socketPath(directory), () => {
4373
5574
  server.off("error", reject);
4374
- resolve10();
5575
+ resolve16();
4375
5576
  });
4376
5577
  });
4377
5578
  await chmod2(socketPath(directory), 384);
5579
+ writePrivateJson(join25(directory, "runtime", "daemon.json"), { pid: process.pid, directory });
4378
5580
  process.once("SIGINT", stop);
4379
5581
  process.once("SIGTERM", stop);
4380
5582
  log.write("daemon.started", void 0, `version=${version}`);
@@ -4394,9 +5596,11 @@ async function runDaemon(directory, version) {
4394
5596
  await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
4395
5597
  await Promise.allSettled(oneShots.values());
4396
5598
  for (const socket of sockets) socket.destroy();
4397
- await new Promise((resolve10) => server.close(() => resolve10()));
5599
+ await new Promise((resolve16) => server.close(() => resolve16()));
4398
5600
  await unlink4(socketPath(directory)).catch(() => {
4399
5601
  });
5602
+ await unlink4(join25(directory, "runtime", "daemon.json")).catch(() => {
5603
+ });
4400
5604
  log.write("daemon.stopped");
4401
5605
  state.close();
4402
5606
  lock.close();
@@ -4413,10 +5617,12 @@ Usage:
4413
5617
  teammy server-info --url URL Check server connection and protocol capabilities
4414
5618
  teammy server-info --help Show connection options
4415
5619
  teammy connect --url URL --project ID [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
5620
+ teammy repair [--stop] Repair or pause this local background client
5621
+ teammy uninstall Remove local service and hooks; keep user data
4416
5622
  teammy status [--root DIR] [--state-dir DIR]
4417
5623
  teammy sync on | off [--root DIR]
4418
5624
  teammy logs [--root DIR] Show recent diagnostic events
4419
- teammy session --help Collect, read and search Agent sessions
5625
+ teammy session --help Collect, read and search the conversation library (\u5BF9\u8BDD\u5E93)
4420
5626
 
4421
5627
  Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
4422
5628
  connect starts background sync; enabled projects resume after login.
@@ -4448,12 +5654,12 @@ async function runCli(args, version) {
4448
5654
  return 1;
4449
5655
  }
4450
5656
  if (command === "_daemon") {
4451
- const { values } = parseArgs4({
5657
+ const { values } = parseArgs6({
4452
5658
  args: args.slice(1),
4453
5659
  options: { "state-dir": { type: "string" } }
4454
5660
  });
4455
5661
  if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
4456
- const directory = resolve9(values["state-dir"]);
5662
+ const directory = resolve15(values["state-dir"]);
4457
5663
  try {
4458
5664
  await runDaemon(directory, version);
4459
5665
  return 0;
@@ -4462,9 +5668,17 @@ async function runCli(args, version) {
4462
5668
  return 1;
4463
5669
  }
4464
5670
  }
5671
+ if (command === "repair" || command === "uninstall") {
5672
+ try {
5673
+ return await runMaintenanceCommand(command, args.slice(1), version);
5674
+ } catch (error) {
5675
+ process.stderr.write(diagnostic(error) + "\n");
5676
+ return 1;
5677
+ }
5678
+ }
4465
5679
  if (command === "session") {
4466
5680
  try {
4467
- return await runSessionCommand(args.slice(1));
5681
+ return await runSessionCommand(args.slice(1), version);
4468
5682
  } catch (error) {
4469
5683
  if (args[1] === "hook") return 0;
4470
5684
  process.stderr.write(
@@ -4497,7 +5711,7 @@ async function runCli(args, version) {
4497
5711
  return 1;
4498
5712
  }
4499
5713
  try {
4500
- const { values } = parseArgs4({
5714
+ const { values } = parseArgs6({
4501
5715
  args: args.slice(1),
4502
5716
  options: {
4503
5717
  help: { type: "boolean", short: "h" },