@teammy/cli 0.0.10 → 0.1.0

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