@teammy/cli 0.0.9 → 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 resolve8 } 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 createHash6 } from "node:crypto";
522
- import { realpath as realpath4 } from "node:fs/promises";
523
- import { homedir } from "node:os";
524
- import { dirname as dirname4, join as join6, resolve as resolve3 } 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 (
@@ -1513,6 +1699,28 @@ var sessionClientMigration = String.raw`
1513
1699
  root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
1514
1700
  );
1515
1701
  `;
1702
+ var sessionSourcesMigration = String.raw`
1703
+ CREATE TABLE session_hook_receipts (root TEXT NOT NULL, agent TEXT NOT NULL, received_at INTEGER NOT NULL, PRIMARY KEY(root,agent));
1704
+ CREATE TABLE session_publications (
1705
+ root TEXT NOT NULL REFERENCES connections(root), session_key TEXT NOT NULL,
1706
+ revision INTEGER NOT NULL DEFAULT 0, hash TEXT, selected_path TEXT,
1707
+ pending_json TEXT, pending_signature TEXT, pending_path TEXT,
1708
+ attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
1709
+ PRIMARY KEY(root,session_key)
1710
+ );
1711
+ INSERT INTO session_publications(root,session_key,revision,hash,selected_path,pending_json,pending_signature,pending_path,attempts,retry_at)
1712
+ SELECT root,session_key,revision,hash,path,pending_json,pending_signature,path,attempts,retry_at FROM session_sources WHERE session_key IS NOT NULL;
1713
+ ALTER TABLE session_sources RENAME TO session_sources_old;
1714
+ DROP INDEX session_source_scheduling;
1715
+ CREATE TABLE session_sources (
1716
+ root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
1717
+ session_key TEXT, signature TEXT, checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0,
1718
+ last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root,path)
1719
+ );
1720
+ INSERT INTO session_sources SELECT root,path,agent,project_dir,session_key,signature,checked_at,verified_at,last_error,adopt_existing FROM session_sources_old;
1721
+ DROP TABLE session_sources_old;
1722
+ CREATE INDEX session_source_scheduling ON session_sources(root,checked_at,path);
1723
+ `;
1516
1724
  var SessionState = class {
1517
1725
  constructor(state, root) {
1518
1726
  this.state = state;
@@ -1532,25 +1740,31 @@ var SessionState = class {
1532
1740
  get(path) {
1533
1741
  return this.sources(path)[0];
1534
1742
  }
1535
- sources(path, limit = 2147483647) {
1743
+ sources(path, limit = 2147483647, before = Number.MAX_SAFE_INTEGER) {
1536
1744
  return this.state.db.prepare(
1537
- `SELECT * FROM session_sources WHERE root=? ${path === void 0 ? "" : "AND path=?"} ORDER BY checked_at, path LIMIT ?`
1538
- ).all(this.root, ...path === void 0 ? [] : [path], limit).map((row) => ({
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
1746
+ FROM session_sources s LEFT JOIN session_publications p USING(root,session_key)
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) => ({
1539
1749
  path: String(row.path),
1540
1750
  agent: row.agent,
1541
1751
  projectDir: String(row.project_dir),
1542
1752
  key: row.session_key,
1543
- revision: Number(row.revision),
1544
- hash: row.hash,
1753
+ revision: Number(row.revision ?? 0),
1754
+ hash: row.hash ?? null,
1545
1755
  signature: row.signature,
1546
1756
  checkedAt: Number(row.checked_at),
1547
1757
  verifiedAt: Number(row.verified_at),
1758
+ parserVersion: Number(row.parser_version),
1759
+ pendingParserVersion: Number(row.pending_parser_version ?? 0),
1548
1760
  lastError: row.last_error,
1549
1761
  adoptExisting: Boolean(row.adopt_existing),
1550
1762
  pending: row.pending_json ? SessionCommitSchema.parse(JSON.parse(String(row.pending_json))) : null,
1551
- pendingSignature: row.pending_signature,
1552
- attempts: Number(row.attempts),
1553
- retryAt: Number(row.retry_at)
1763
+ pendingSignature: row.pending_signature ?? null,
1764
+ pendingPath: row.pending_path ?? null,
1765
+ selectedPath: row.selected_path ?? null,
1766
+ attempts: Number(row.attempts ?? 0),
1767
+ retryAt: Number(row.retry_at ?? 0)
1554
1768
  }));
1555
1769
  }
1556
1770
  status() {
@@ -1560,38 +1774,84 @@ var SessionState = class {
1560
1774
  }));
1561
1775
  }
1562
1776
  untrack(path) {
1563
- this.state.db.prepare("DELETE FROM session_sources WHERE root=? AND path=?").run(this.root, path);
1777
+ transaction(this.state.db, () => {
1778
+ const source = this.get(path);
1779
+ this.state.db.prepare("DELETE FROM session_sources WHERE root=? AND path=?").run(this.root, path);
1780
+ if (!source?.key) return;
1781
+ if (!this.state.db.prepare("SELECT 1 FROM session_sources WHERE root=? AND session_key=?").get(this.root, source.key)) {
1782
+ this.state.db.prepare("DELETE FROM session_publications WHERE root=? AND session_key=?").run(this.root, source.key);
1783
+ } else {
1784
+ this.state.db.prepare(
1785
+ "UPDATE session_publications SET selected_path=NULL WHERE root=? AND session_key=? AND selected_path=?"
1786
+ ).run(this.root, source.key, path);
1787
+ this.state.db.prepare(
1788
+ "UPDATE session_publications SET pending_json=NULL,pending_signature=NULL,pending_path=NULL,attempts=0,retry_at=0 WHERE root=? AND session_key=? AND pending_path=?"
1789
+ ).run(this.root, source.key, path);
1790
+ }
1791
+ });
1564
1792
  }
1565
1793
  checked(path, error = null) {
1566
- this.state.db.prepare("UPDATE session_sources SET checked_at=?, last_error=? WHERE root=? AND path=?").run(Date.now(), error, this.root, path);
1794
+ this.state.db.prepare("UPDATE session_sources SET checked_at=?,last_error=? WHERE root=? AND path=?").run(Date.now(), error, this.root, path);
1567
1795
  }
1568
1796
  stage(path, request, signature2) {
1569
1797
  this.state.db.prepare(
1570
- "UPDATE session_sources SET session_key=?, pending_json=?, pending_signature=?, attempts=0, retry_at=0 WHERE root=? AND path=? AND pending_json IS NULL"
1571
- ).run(request.sessionKey, JSON.stringify(request), signature2, this.root, path);
1798
+ `UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,pending_parser_version=?,attempts=0,retry_at=0
1799
+ WHERE root=? AND session_key=? AND pending_json IS NULL`
1800
+ ).run(
1801
+ JSON.stringify(request),
1802
+ signature2,
1803
+ path,
1804
+ parserVersions[this.get(path).agent],
1805
+ this.root,
1806
+ request.sessionKey
1807
+ );
1808
+ }
1809
+ acknowledge(path, revision, hash2, signature2, parserVersion) {
1810
+ const source = this.get(path);
1811
+ this.state.db.prepare(
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=?`
1813
+ ).run(revision, hash2, path, this.root, source.key);
1814
+ this.observed(path, signature2, parserVersion);
1572
1815
  }
1573
- acknowledge(path, revision, hash2, signature2) {
1816
+ observed(path, signature2, parserVersion = parserVersions[this.get(path).agent]) {
1574
1817
  this.state.db.prepare(
1575
- `UPDATE session_sources SET revision=?, hash=?, signature=?, verified_at=?, pending_json=NULL, pending_signature=NULL, attempts=0, retry_at=0, last_error=NULL, adopt_existing=0 WHERE root=? AND path=?`
1576
- ).run(revision, hash2, 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);
1577
1820
  }
1578
1821
  baseline(path, key, revision) {
1579
- this.state.db.prepare("UPDATE session_sources SET session_key=?, revision=? WHERE root=? AND path=?").run(key, revision, this.root, path);
1822
+ this.state.db.prepare(
1823
+ "INSERT OR IGNORE INTO session_publications(root,session_key,revision) VALUES (?,?,?)"
1824
+ ).run(this.root, key, revision);
1825
+ this.state.db.prepare("UPDATE session_sources SET session_key=? WHERE root=? AND path=?").run(key, this.root, path);
1826
+ }
1827
+ publication(key) {
1828
+ return this.state.db.prepare("SELECT 1 FROM session_publications WHERE root=? AND session_key=?").get(this.root, key);
1580
1829
  }
1581
1830
  retry() {
1582
- this.state.db.prepare("UPDATE session_sources SET retry_at=0 WHERE root=?").run(this.root);
1831
+ this.state.db.prepare("UPDATE session_publications SET retry_at=0 WHERE root=?").run(this.root);
1583
1832
  }
1584
1833
  postpone(source, error, blocked) {
1585
1834
  const at = blocked ? Number.MAX_SAFE_INTEGER : Date.now() + Math.min(9e5, 6e4 * 2 ** Math.min(source.attempts, 4));
1586
1835
  this.state.db.prepare(
1587
- "UPDATE session_sources SET retry_at=?, attempts=attempts+1, last_error=? WHERE root=? AND path=?"
1588
- ).run(at, error, this.root, source.path);
1836
+ "UPDATE session_publications SET retry_at=?,attempts=attempts+1 WHERE root=? AND session_key=?"
1837
+ ).run(at, this.root, source.key);
1838
+ this.checked(source.path, error);
1589
1839
  }
1590
1840
  discover(agent, directory, projectDir) {
1841
+ const prior = this.directories().find((item) => item.directory === directory);
1842
+ if (prior?.agent === agent && prior.projectDir === projectDir) return;
1591
1843
  this.state.db.prepare(
1592
1844
  "INSERT INTO session_discovery VALUES (?,?,?,?) ON CONFLICT(root,directory) DO UPDATE SET agent=excluded.agent,project_dir=excluded.project_dir"
1593
1845
  ).run(this.root, directory, agent, projectDir);
1594
- 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 + "/");
1847
+ }
1848
+ receivedHook(agent) {
1849
+ this.state.db.prepare(
1850
+ "INSERT INTO session_hook_receipts VALUES (?,?,?) ON CONFLICT(root,agent) DO UPDATE SET received_at=excluded.received_at"
1851
+ ).run(this.root, agent, Date.now());
1852
+ }
1853
+ hooks() {
1854
+ return this.state.db.prepare("SELECT agent,received_at FROM session_hook_receipts WHERE root=? ORDER BY agent").all(this.root);
1595
1855
  }
1596
1856
  directories() {
1597
1857
  return this.state.db.prepare("SELECT * FROM session_discovery WHERE root=? ORDER BY directory").all(this.root).map((row) => ({
@@ -1604,32 +1864,75 @@ var SessionState = class {
1604
1864
 
1605
1865
  // apps/client/state.ts
1606
1866
  var LocalBindingSchema = BindingSchema.extend({
1607
- root: z9.string(),
1608
- serverUrl: z9.string(),
1609
- token: z9.string()
1867
+ root: z11.string(),
1868
+ serverUrl: z11.string(),
1869
+ token: z11.string()
1610
1870
  });
1611
- var ClientState = class extends FilePublicationState {
1612
- constructor(directory) {
1613
- const db = openDatabase(join4(directory, "client.sqlite3"), [
1614
- String.raw`
1871
+ var clientMigrations = [
1872
+ String.raw`
1615
1873
  CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
1616
1874
  `,
1617
- String.raw`
1875
+ String.raw`
1618
1876
  CREATE TABLE publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
1619
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));
1620
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);
1621
1879
  `,
1622
- syncMigration,
1623
- sessionClientMigration,
1624
- projectClientMigration,
1625
- controlClientMigration,
1626
- runtimeMigration,
1627
- projectFilesClientMigration
1628
- ]);
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
+ }
1629
1927
  super(db);
1928
+ this.access = access2;
1630
1929
  }
1631
1930
  close() {
1632
- this.db.close();
1931
+ try {
1932
+ this.db.close();
1933
+ } finally {
1934
+ this.access?.close();
1935
+ }
1633
1936
  }
1634
1937
  assertRootAvailable(root) {
1635
1938
  for (const row of this.db.prepare("SELECT root FROM connections").all()) {
@@ -1645,7 +1948,7 @@ var ClientState = class extends FilePublicationState {
1645
1948
  }
1646
1949
  deviceFor(root) {
1647
1950
  this.assertRootAvailable(root);
1648
- 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());
1649
1952
  return String(
1650
1953
  this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
1651
1954
  );
@@ -1687,11 +1990,14 @@ async function stateLocation(root, directory) {
1687
1990
  return canonical;
1688
1991
  }
1689
1992
 
1690
- // apps/client/sessions/hooks.ts
1691
- import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
1692
- import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
1693
- import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
1694
- import { z as z10 } from "zod";
1993
+ // apps/client/sessions/setup.ts
1994
+ import { execFile as execFile2 } from "node:child_process";
1995
+ import { promisify as promisify2 } from "node:util";
1996
+ import { access, mkdir as mkdir5, readFile as readFile2, writeFile, realpath as realpath4 } from "node:fs/promises";
1997
+ import { constants as constants2 } from "node:fs";
1998
+ import { homedir } from "node:os";
1999
+ import { dirname as dirname6, join as join10, resolve as resolve4 } from "node:path";
2000
+ import { createHash as createHash6 } from "node:crypto";
1695
2001
 
1696
2002
  // apps/client/sessions/normalize.ts
1697
2003
  function object(value) {
@@ -1702,91 +2008,118 @@ function textContent(value) {
1702
2008
  if (!Array.isArray(value)) return "";
1703
2009
  return value.flatMap((block) => {
1704
2010
  const part = object(block);
1705
- return ["text", "input_text", "output_text"].includes(String(part.type)) && typeof part.text === "string" ? [part.text] : [];
2011
+ return ["text", "Text", "input_text", "output_text"].includes(String(part.type)) && typeof part.text === "string" ? [part.text] : [];
1706
2012
  }).join("\n");
1707
2013
  }
1708
- function parseJsonl(text) {
1709
- const lines = text.split("\n");
1710
- const rows = [];
1711
- for (let index = 0; index < lines.length; index++) {
1712
- const line = lines[index];
1713
- if (!line.trim()) continue;
1714
- if (Buffer.byteLength(line) > 8 * 1024 * 1024) throw new Error("Session record exceeds 8 MiB.");
1715
- let parsed;
1716
- try {
1717
- parsed = JSON.parse(line);
1718
- } catch {
1719
- if (index === lines.length - 1 && !text.endsWith("\n")) return { rows, incomplete: true };
1720
- throw new Error(`Invalid session JSON at line ${index + 1}.`);
1721
- }
1722
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1723
- throw new Error(`Invalid session record at line ${index + 1}.`);
1724
- rows.push({ line: index + 1, value: object(parsed) });
2014
+ function parseRecord(line) {
2015
+ try {
2016
+ const value = JSON.parse(line);
2017
+ if (value && typeof value === "object" && !Array.isArray(value)) return object(value);
2018
+ } catch {
1725
2019
  }
1726
- return { rows, incomplete: false };
2020
+ return void 0;
1727
2021
  }
1728
- function normalizeJsonl(agent, text) {
1729
- const { rows, incomplete } = parseJsonl(text);
1730
- let nativeId;
1731
- let cwd;
1732
- const messages = [];
1733
- const ids = /* @__PURE__ */ new Map();
1734
- const add = (role, content, id, line) => {
2022
+ var JsonlNormalizer = class {
2023
+ constructor(agent, cursor) {
2024
+ this.agent = agent;
2025
+ if (agent === "cursor" && cursor) {
2026
+ this.nativeId = cursor.nativeId;
2027
+ this.cwd = cursor.cwd;
2028
+ }
2029
+ }
2030
+ agent;
2031
+ nativeId;
2032
+ cwd;
2033
+ turn = "legacy";
2034
+ piParents = /* @__PURE__ */ new Map();
2035
+ piLeaf = null;
2036
+ piTree = false;
2037
+ candidates = [];
2038
+ add(role, content, id, line, visible = false) {
1735
2039
  if (role !== "user" && role !== "assistant") return;
1736
- const text2 = textContent(content);
1737
- if (!text2.trim()) return;
1738
- const base = typeof id === "string" && id ? id : `line:${line}`;
1739
- const count = (ids.get(base) ?? 0) + 1;
1740
- ids.set(base, count);
1741
- messages.push({ id: `${base}#${count}`, role, text: text2, source: `line:${line}` });
1742
- };
1743
- for (const { line, value: row } of rows) {
1744
- if (agent === "claude-code") {
1745
- if (row.isSidechain === true) continue;
2040
+ const text = textContent(content);
2041
+ if (text.trim())
2042
+ this.candidates.push({ role, text, id, line, group: `${this.turn}:${role}`, visible });
2043
+ }
2044
+ consume(row, line) {
2045
+ if (this.agent === "claude-code") {
2046
+ if (row.isSidechain === true) return;
1746
2047
  if (row.sessionId) {
1747
- if (nativeId && nativeId !== row.sessionId)
2048
+ if (this.nativeId && this.nativeId !== row.sessionId)
1748
2049
  throw new Error("Mixed Claude session identities.");
1749
- nativeId = row.sessionId;
2050
+ this.nativeId = row.sessionId;
1750
2051
  }
1751
- if (row.cwd && !cwd) cwd = row.cwd;
1752
- if (row.type === "user" || row.type === "assistant") {
2052
+ if (row.cwd && !this.cwd) this.cwd = row.cwd;
2053
+ if ((row.type === "user" || row.type === "assistant") && row.isMeta !== true) {
1753
2054
  const message2 = object(row.message);
1754
- add(message2.role ?? row.type, message2.content, row.uuid, line);
2055
+ this.add(message2.role ?? row.type, message2.content, row.uuid, line);
1755
2056
  }
1756
- } else if (agent === "codex") {
2057
+ } else if (this.agent === "codex") {
1757
2058
  const payload = object(row.payload);
1758
2059
  if (row.type === "session_meta") {
1759
- if (nativeId && nativeId !== payload.id) throw new Error("Mixed Codex session identities.");
1760
- nativeId = payload.id;
1761
- cwd = payload.cwd;
2060
+ if (this.nativeId && this.nativeId !== payload.id)
2061
+ throw new Error("Mixed Codex session identities.");
2062
+ this.nativeId = payload.id;
2063
+ this.cwd = payload.cwd;
2064
+ }
2065
+ if (typeof payload.turn_id === "string") this.turn = payload.turn_id;
2066
+ if (row.type === "event_msg" && payload.type === "item_completed") {
2067
+ const item = object(payload.item);
2068
+ if (item.type === "UserMessage" || item.type === "AgentMessage")
2069
+ this.add(
2070
+ item.type === "UserMessage" ? "user" : "assistant",
2071
+ item.content,
2072
+ item.id,
2073
+ line,
2074
+ true
2075
+ );
2076
+ }
2077
+ if (row.type === "response_item" && payload.type === "message" && payload.channel !== "analysis") {
2078
+ const kinds = object(payload.internal_chat_message_metadata_passthrough).content_item_kinds;
2079
+ if (payload.role === "user" && Array.isArray(kinds) && !kinds.some((k) => String(k).startsWith("user.")))
2080
+ return;
2081
+ this.add(payload.role, payload.content, payload.id, line);
1762
2082
  }
1763
- if (row.type === "response_item" && payload.type === "message" && payload.channel !== "analysis")
1764
- add(payload.role, payload.content, payload.id, line);
1765
- } else if (agent === "pi") {
2083
+ } else if (this.agent === "cursor") {
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);
2089
+ this.add(row.role ?? message2.role, message2.content ?? row.content, row.id ?? row.uuid, line);
2090
+ } else if (this.agent === "pi") {
1766
2091
  if (row.type === "session") {
1767
2092
  if (row.version !== 3) throw new Error("Unsupported Pi session version (expected 3).");
1768
- if (nativeId) throw new Error("Multiple Pi session headers.");
1769
- nativeId = row.id;
1770
- cwd = row.cwd;
2093
+ if (this.nativeId) throw new Error("Multiple Pi session headers.");
2094
+ this.nativeId = row.id;
2095
+ this.cwd = row.cwd;
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;
1771
2104
  }
1772
2105
  if (row.type === "message") {
1773
2106
  const message2 = object(row.message);
1774
- add(message2.role, message2.content, row.id, line);
2107
+ this.add(message2.role, message2.content, row.id, line);
1775
2108
  }
1776
2109
  } else {
1777
2110
  if (row.type === "session") {
1778
2111
  if (row.version !== 0) throw new Error("Unsupported DSH session version (expected 0).");
1779
- if (nativeId) throw new Error("Multiple DSH session headers.");
2112
+ if (this.nativeId) throw new Error("Multiple DSH session headers.");
1780
2113
  if (Number(row.delegationDepth ?? 0) > 0)
1781
2114
  throw new Error("DSH delegated sessions are not collected.");
1782
- nativeId = row.id;
1783
- cwd = row.cwd;
2115
+ this.nativeId = row.id;
2116
+ this.cwd = row.cwd;
1784
2117
  }
1785
2118
  if (row.type === "user/message" || row.type === "assistant/message") {
1786
2119
  const data = object(row.data);
1787
2120
  const message2 = row.type === "assistant/message" ? object(data.message) : data;
1788
- if (object(message2.source).kind === "plugin") continue;
1789
- add(
2121
+ if (object(message2.source).kind === "plugin") return;
2122
+ this.add(
1790
2123
  row.type === "user/message" ? "user" : "assistant",
1791
2124
  message2.content,
1792
2125
  `seq:${row.seq}`,
@@ -1795,103 +2128,1221 @@ function normalizeJsonl(agent, text) {
1795
2128
  }
1796
2129
  }
1797
2130
  }
1798
- return {
1799
- snapshot: SessionSnapshotSchema.parse({
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
+ }
2141
+ const visibleGroups = new Set(this.candidates.filter((m) => m.visible).map((m) => m.group));
2142
+ const ids = /* @__PURE__ */ new Map();
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) => {
2151
+ const base = typeof m.id === "string" && m.id ? m.id : `line:${m.line}`;
2152
+ const count = (ids.get(base) ?? 0) + 1;
2153
+ ids.set(base, count);
2154
+ return { id: `${base}#${count}`, role: m.role, text: m.text, source: `line:${m.line}` };
2155
+ });
2156
+ return SessionSnapshotSchema.parse({
1800
2157
  formatVersion: 1,
1801
2158
  adapterVersion: 1,
1802
- agent,
1803
- nativeId,
1804
- cwd,
2159
+ agent: this.agent,
2160
+ nativeId: this.nativeId,
2161
+ cwd: this.cwd,
1805
2162
  messages
1806
- }),
1807
- incomplete
1808
- };
1809
- }
2163
+ });
2164
+ }
2165
+ };
1810
2166
 
1811
- // apps/client/sessions/hooks.ts
1812
- var HintSchema = z10.object({
1813
- agent: AgentSchema,
1814
- path: z10.string().min(1).max(4096).refine(isAbsolute3),
1815
- projectDir: z10.string().min(1).max(4096).refine(isAbsolute3)
1816
- });
1817
- function inbox(directory, root) {
1818
- return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
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;
1819
2178
  }
1820
- async function recordHint(directory, root, input) {
1821
- const hint = HintSchema.parse(input);
1822
- const targetDir = inbox(directory, root);
1823
- await mkdir4(targetDir, { recursive: true, mode: 448 });
1824
- const target = join5(
1825
- targetDir,
1826
- `${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
1827
- );
1828
- const temporary = `${target}.${randomUUID4()}.tmp`;
1829
- const handle = await open2(temporary, "wx", 384);
1830
- try {
1831
- await handle.writeFile(JSON.stringify(hint));
1832
- } finally {
1833
- await handle.close();
1834
- }
1835
- await rename2(temporary, target);
2179
+ function saveRegistration(directory, value) {
2180
+ writePrivateJson(registrationPath(directory, value.root), value);
1836
2181
  }
1837
- async function consumeHints(directory, root, sessions) {
1838
- const issues = [];
1839
- let dir;
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;
1840
2203
  try {
1841
- dir = await opendir(inbox(directory, root));
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;
1842
2211
  } catch (error) {
1843
- if (error.code === "ENOENT") return issues;
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.");
1844
2215
  throw error;
1845
2216
  }
1846
- let count = 0;
1847
- for await (const entry of dir) {
1848
- if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
1849
- if (count++ >= 50) break;
1850
- const path = join5(inbox(directory, root), entry.name);
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")]) {
1851
2235
  try {
1852
- const handle = await open2(path, "r");
1853
- let body;
1854
- try {
1855
- if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
1856
- body = await handle.readFile("utf8");
1857
- } finally {
1858
- await handle.close();
1859
- }
1860
- const hint = HintSchema.parse(JSON.parse(body));
1861
- sessions.track(hint.agent, hint.path, hint.projectDir);
1862
- await unlink2(path);
2236
+ const settings = JSON.parse(await readFile(path, "utf8"));
2237
+ if (typeof settings?.sessionDir === "string" && settings.sessionDir)
2238
+ configured = settings.sessionDir;
1863
2239
  } catch (error) {
1864
- issues.push(
1865
- `hook hint ${entry.name}: ${error instanceof Error ? error.message : "invalid hint"}`
1866
- );
2240
+ if (!missing(error)) throw new Error(`Unable to read Pi session directory setting: ${path}`);
1867
2241
  }
1868
2242
  }
1869
- return issues;
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
+ ];
1870
2250
  }
1871
- async function hookPayload() {
1872
- let size = 0;
1873
- const parts = [];
1874
- const timeout = setTimeout(() => process.stdin.destroy(new Error("Hook input timeout.")), 500);
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;
1875
2293
  try {
1876
- for await (const part of process.stdin) {
1877
- const bytes = Buffer.from(part);
1878
- size += bytes.length;
1879
- if (size > 16384) throw new Error("Hook payload too large.");
1880
- parts.push(bytes);
1881
- }
1882
- return object(JSON.parse(Buffer.concat(parts).toString("utf8")));
1883
- } finally {
1884
- clearTimeout(timeout);
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;
1885
2321
  }
1886
2322
  }
1887
2323
 
1888
- // apps/client/sessions/command.ts
1889
- var help = `Usage: teammy session COMMAND [options]
1890
-
1891
- track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
1892
- untrack --file PATH (does not delete server history)
1893
- discover --agent AGENT [--directory DIR] [--project-dir DIR]
1894
- undiscover --directory DIR (also untrack any previously discovered sources to stop them)
2324
+ // apps/client/sessions/setup.ts
2325
+ var exec2 = promisify2(execFile2);
2326
+ var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
2327
+ async function exists(path, mode = constants2.F_OK) {
2328
+ try {
2329
+ await access(path, mode);
2330
+ return true;
2331
+ } catch {
2332
+ return false;
2333
+ }
2334
+ }
2335
+ async function setupSessions(state, root, directory, options = {}) {
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"));
2339
+ try {
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: []
2402
+ }
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);
2423
+ }
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
+ }
2448
+ }
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);
2454
+ try {
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
2509
+ });
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
+ }
2541
+ }
2542
+ } catch {
2543
+ }
2544
+ } catch (error) {
2545
+ issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
2546
+ }
2547
+ }
2548
+ return { configured, issues };
2549
+ } finally {
2550
+ lock.close();
2551
+ }
2552
+ }
2553
+ function hookMarker(directory, root) {
2554
+ return `teammy-session-${rootKey(directory)}-${rootKey(root)}`;
2555
+ }
2556
+ function setDiscoveryPreference(directory, root, source, enabled) {
2557
+ const lock = acquireRootLock("hooks", join10(directory, "runtime"));
2558
+ try {
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);
2566
+ } finally {
2567
+ lock.close();
2568
+ }
2569
+ }
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;
2597
+ try {
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();
2604
+ }
2605
+ for (const root of roots) {
2606
+ try {
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;
2753
+ try {
2754
+ if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
2755
+ body = await handle.readFile("utf8");
2756
+ } finally {
2757
+ await handle.close();
2758
+ }
2759
+ const hint = HintSchema.parse(JSON.parse(body));
2760
+ sessions.receivedHook(hint.agent);
2761
+ if (hint.path) sessions.track(hint.agent, hint.path, hint.projectDir);
2762
+ if (hint.nativeId) await refresh?.();
2763
+ await unlink2(path);
2764
+ } catch (error) {
2765
+ issues.push(
2766
+ `hook hint ${entry.name}: ${error instanceof Error ? error.message : "invalid hint"}`
2767
+ );
2768
+ }
2769
+ }
2770
+ return issues;
2771
+ }
2772
+ async function hookPayload() {
2773
+ let size = 0;
2774
+ const parts = [];
2775
+ const timeout = setTimeout(() => process.stdin.destroy(new Error("Hook input timeout.")), 500);
2776
+ try {
2777
+ for await (const part of process.stdin) {
2778
+ const bytes = Buffer.from(part);
2779
+ size += bytes.length;
2780
+ if (size > 4 * 1024 * 1024) throw new Error("Hook payload too large.");
2781
+ parts.push(bytes);
2782
+ }
2783
+ return object(JSON.parse(Buffer.concat(parts).toString("utf8")));
2784
+ } finally {
2785
+ clearTimeout(timeout);
2786
+ }
2787
+ }
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
+
3335
+ // apps/client/sessions/command.ts
3336
+ var help = `Usage: teammy session COMMAND [options]
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)
3341
+ setup Configure project hooks and native discovery (also done by connect)
3342
+ track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
3343
+ untrack --file PATH (does not delete server history)
3344
+ discover --agent AGENT [--directory DIR] [--project-dir DIR]
3345
+ undiscover --directory DIR (also untrack any previously discovered sources to stop them)
1895
3346
  sources Show registered sources, discovery directories and collection errors
1896
3347
  hook --agent AGENT [--file PATH] [--project-dir DIR]; accepts JSON on stdin
1897
3348
  list [--after N] [--limit N]
@@ -1904,17 +3355,21 @@ track/discover only register local sources. Run teammy sync to collect and publi
1904
3355
  Discover uses native default locations except DSH, which requires --directory.
1905
3356
  Hook only records a local hint, emits no stdout, and always exits successfully.
1906
3357
  `;
1907
- async function runSessionCommand(args) {
3358
+ async function runSessionCommand(args, version) {
1908
3359
  const command = args[0];
1909
3360
  if (!command || ["--help", "-h", "help"].includes(command)) {
1910
3361
  process.stdout.write(help);
1911
3362
  return 0;
1912
3363
  }
1913
- const { values } = parseArgs({
3364
+ if (command === "hook") {
3365
+ await runHook(args.slice(1));
3366
+ return 0;
3367
+ }
3368
+ const { values } = parseArgs2({
1914
3369
  args: args.slice(1),
1915
3370
  options: {
1916
- root: { type: "string", default: resolve3("workspaces") },
1917
- "state-dir": { type: "string", default: join6(homedir(), ".teammy", "client") },
3371
+ root: { type: "string", default: resolve8("workspaces") },
3372
+ "state-dir": { type: "string", default: join16(homedir4(), ".teammy", "client") },
1918
3373
  agent: { type: "string" },
1919
3374
  file: { type: "string" },
1920
3375
  directory: { type: "string" },
@@ -1926,27 +3381,25 @@ async function runSessionCommand(args) {
1926
3381
  query: { type: "string" },
1927
3382
  after: { type: "string", default: "0" },
1928
3383
  limit: { type: "string", default: "50" },
1929
- help: { type: "boolean", short: "h" }
3384
+ help: { type: "boolean", short: "h" },
3385
+ all: { type: "boolean" }
1930
3386
  }
1931
3387
  });
1932
3388
  if (values.help) {
1933
3389
  process.stdout.write(help);
1934
3390
  return 0;
1935
3391
  }
1936
- const root = await realpath4(resolve3(values.root));
1937
- const directory = await stateLocation(root, values["state-dir"]);
1938
- const projectDir = await realpath4(resolve3(values["project-dir"] ?? dirname4(root)));
1939
- if (command === "hook") {
1940
- const payload = values.file ? {} : await hookPayload();
1941
- const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
1942
- if (typeof file === "string" && file)
1943
- await recordHint(directory, root, {
1944
- agent: AgentSchema.parse(values.agent),
1945
- path: resolve3(file),
1946
- projectDir
1947
- });
1948
- return 0;
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;
1949
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);
1950
3403
  const state = new ClientState(directory);
1951
3404
  try {
1952
3405
  const binding = state.binding(root);
@@ -1954,9 +3407,10 @@ async function runSessionCommand(args) {
1954
3407
  const sessions = new SessionState(state, root);
1955
3408
  const client = new HubClient(binding.serverUrl, 1e4, binding);
1956
3409
  let output;
1957
- if (command === "track" || command === "untrack") {
3410
+ if (command === "setup") output = await setupSessions(state, root, directory, { enable: true });
3411
+ else if (command === "track" || command === "untrack") {
1958
3412
  if (!values.file) throw new Error("--file is required.");
1959
- const path = resolve3(values.file);
3413
+ const path = resolve8(values.file);
1960
3414
  if (command === "track")
1961
3415
  sessions.track(
1962
3416
  AgentSchema.parse(values.agent),
@@ -1969,27 +3423,38 @@ async function runSessionCommand(args) {
1969
3423
  } else if (command === "discover" || command === "undiscover") {
1970
3424
  if (command === "undiscover") {
1971
3425
  if (!values.directory) throw new Error("--directory is required.");
1972
- state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath4(resolve3(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);
1973
3429
  } else {
1974
3430
  const agent = AgentSchema.parse(values.agent);
1975
3431
  const defaults = {
1976
- "claude-code": join6(homedir(), ".claude", "projects"),
1977
- codex: join6(process.env.CODEX_HOME ?? join6(homedir(), ".codex"), "sessions"),
1978
- cursor: join6(
1979
- process.env.CURSOR_CONFIG_DIR ?? join6(homedir(), ".cursor"),
3432
+ "claude-code": join16(
3433
+ process.env.CLAUDE_CONFIG_DIR ?? join16(homedir4(), ".claude"),
3434
+ "projects"
3435
+ ),
3436
+ codex: join16(process.env.CODEX_HOME ?? join16(homedir4(), ".codex"), "sessions"),
3437
+ cursor: join16(
3438
+ process.env.CURSOR_CONFIG_DIR ?? join16(homedir4(), ".cursor"),
1980
3439
  "chats",
1981
- createHash6("md5").update(projectDir).digest("hex")
3440
+ createHash8("md5").update(projectDir).digest("hex")
1982
3441
  ),
1983
- pi: join6(process.env.PI_CODING_AGENT_DIR ?? join6(homedir(), ".pi", "agent"), "sessions"),
3442
+ pi: join16(process.env.PI_CODING_AGENT_DIR ?? join16(homedir4(), ".pi", "agent"), "sessions"),
1984
3443
  dsh: void 0
1985
3444
  };
1986
3445
  const path = values.directory ?? defaults[agent];
1987
3446
  if (!path) throw new Error("DSH requires its configured persistence --directory.");
1988
- sessions.discover(agent, await realpath4(resolve3(path)), projectDir);
3447
+ const source = await realpath6(resolve8(path));
3448
+ setDiscoveryPreference(directory, root, source, true);
3449
+ sessions.discover(agent, source, projectDir);
1989
3450
  }
1990
3451
  output = sessions.directories();
1991
3452
  } else if (command === "sources")
1992
- output = { sources: sessions.status(), discovery: sessions.directories() };
3453
+ output = {
3454
+ sources: sessions.status(),
3455
+ discovery: sessions.directories(),
3456
+ hooks: sessions.hooks()
3457
+ };
1993
3458
  else {
1994
3459
  const after = Number(values.after);
1995
3460
  const limit = Number(values.limit);
@@ -2020,54 +3485,33 @@ async function runSessionCommand(args) {
2020
3485
  }
2021
3486
 
2022
3487
  // apps/client/sync-command.ts
2023
- import { parseArgs as parseArgs3 } from "node:util";
3488
+ import { parseArgs as parseArgs4 } from "node:util";
2024
3489
  import { setTimeout as setTimeout4 } from "node:timers/promises";
2025
3490
 
2026
3491
  // apps/client/client-command.ts
2027
- import { parseArgs as parseArgs2 } from "node:util";
2028
- import { join as join11, resolve as resolve5 } from "node:path";
2029
- import { homedir as homedir3 } 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";
2030
3495
  import { createInterface as createInterface2 } from "node:readline/promises";
2031
3496
 
2032
3497
  // apps/client/connection.ts
2033
- import { readFile, mkdir as mkdir5 } from "node:fs/promises";
3498
+ import { readFile as readFile5, mkdir as mkdir10 } from "node:fs/promises";
2034
3499
  import { createInterface } from "node:readline/promises";
2035
3500
 
2036
- // apps/client/root-lock.ts
2037
- import { createHash as createHash7 } from "node:crypto";
2038
- import { join as join7 } from "node:path";
2039
- function acquireRootLock(root, stateDirectory) {
2040
- let lock;
2041
- try {
2042
- lock = openDatabase(
2043
- join7(stateDirectory, "locks", `${createHash7("sha256").update(root).digest("hex")}.sqlite3`),
2044
- [],
2045
- 0
2046
- );
2047
- lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
2048
- return lock;
2049
- } catch (error) {
2050
- lock?.close();
2051
- if (error instanceof Error && /locked|busy/.test(error.message))
2052
- throw new Error("Another sync or connect process is using this root.");
2053
- throw error;
2054
- }
2055
- }
2056
-
2057
3501
  // apps/client/local-config.ts
2058
- import { constants as constants2 } from "node:fs";
2059
- import { open as open3, writeFile } from "node:fs/promises";
2060
- import { join as join8 } from "node:path";
2061
- import { z as z11 } from "zod";
3502
+ import { constants as constants3 } from "node:fs";
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";
2062
3506
  import ignore from "ignore";
2063
3507
  var LOCAL_CONFIG_NAME = ".teammy-local.json";
2064
- var LocalConfigSchema = z11.object({
2065
- 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: [] })
2066
3510
  }).strict();
2067
3511
  async function ensureLocalConfig(root) {
2068
3512
  try {
2069
- await writeFile(
2070
- join8(root, LOCAL_CONFIG_NAME),
3513
+ await writeFile3(
3514
+ join17(root, LOCAL_CONFIG_NAME),
2071
3515
  JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
2072
3516
  { flag: "wx", mode: 384 }
2073
3517
  );
@@ -2079,8 +3523,8 @@ async function loadLocalRules(root) {
2079
3523
  let rules = [];
2080
3524
  try {
2081
3525
  const file = await open3(
2082
- join8(root, LOCAL_CONFIG_NAME),
2083
- constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
3526
+ join17(root, LOCAL_CONFIG_NAME),
3527
+ constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
2084
3528
  );
2085
3529
  try {
2086
3530
  const before = await file.stat();
@@ -2125,6 +3569,10 @@ async function connectProject(state, root, directory, options) {
2125
3569
  if (existing && options.project && options.project !== existing.workspaceId)
2126
3570
  throw new Error("This root belongs to a different project. Use a different local root.");
2127
3571
  const info = await transport.serverInfo();
3572
+ if (!info.capabilities.includes("atomic-file-publications"))
3573
+ throw new Error(
3574
+ "Update the server before connecting: complete directory publications are required."
3575
+ );
2128
3576
  if (!info.capabilities.includes("multi-project"))
2129
3577
  throw new Error(
2130
3578
  "This client requires a multi-project server. Update the server before connecting."
@@ -2136,7 +3584,7 @@ async function connectProject(state, root, directory, options) {
2136
3584
  const client2 = new HubClient(existing.serverUrl, 1e4, existing);
2137
3585
  await client2.members();
2138
3586
  } else if (options.tokenFile) {
2139
- const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
3587
+ const token = CredentialSchema.parse((await readFile5(options.tokenFile, "utf8")).trim());
2140
3588
  const deviceId = state.deviceFor(root);
2141
3589
  const client2 = new HubClient(options.url, 1e4, { token, deviceId });
2142
3590
  const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
@@ -2148,256 +3596,50 @@ async function connectProject(state, root, directory, options) {
2148
3596
  if (!member && process.stdin.isTTY) {
2149
3597
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2150
3598
  try {
2151
- member = (await rl.question("Member ID: ")).trim();
2152
- } finally {
2153
- rl.close();
2154
- }
2155
- }
2156
- if (!member) throw new Error("--member is required when input is not interactive.");
2157
- NameSchema.parse(member);
2158
- if (existing && existing.memberId !== member)
2159
- throw new Error("This root belongs to a different member.");
2160
- const projectId = options.project ?? existing?.workspaceId;
2161
- if (!projectId)
2162
- throw new Error(
2163
- "--project is required for a new connection; copy the command from the project settings."
2164
- );
2165
- NameSchema.parse(projectId);
2166
- const request = project.enrollment(
2167
- transport.url,
2168
- member,
2169
- projectId,
2170
- Boolean(options.reconnect)
2171
- );
2172
- const binding2 = await transport.enroll(request);
2173
- state.saveBinding(root, transport.url, request.credential, binding2);
2174
- project.completeEnrollment();
2175
- }
2176
- const binding = state.binding(root);
2177
- const client = new HubClient(binding.serverUrl, 1e4, binding);
2178
- if (info.capabilities.includes("project-config")) {
2179
- try {
2180
- await applyProjectFile(project, await client.projectConfig());
2181
- } catch (error) {
2182
- if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
2183
- throw error;
2184
- }
2185
- }
2186
- const own = await metadata(root, binding.memberId);
2187
- if (own && !own.directory) throw new Error("Member path is not a directory.");
2188
- if (!own) await mkdir5(await safePath(root, binding.memberId));
2189
- await ensureLocalConfig(root);
2190
- const { token: _, ...visible } = binding;
2191
- return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
2192
- } finally {
2193
- lock.close();
2194
- }
2195
- }
2196
-
2197
- // apps/client/runtime/ipc.ts
2198
- import { createConnection } from "node:net";
2199
- import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
2200
- import { join as join9 } from "node:path";
2201
- import { z as z12 } from "zod";
2202
- var RequestSchema = z12.object({
2203
- method: z12.enum(["ping", "wake", "status", "once"]),
2204
- root: z12.string().max(4096).optional()
2205
- }).strict();
2206
- function socketPath(directory) {
2207
- return join9(
2208
- "/tmp",
2209
- `teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
2210
- "control.sock"
2211
- );
2212
- }
2213
- async function prepareSocketDirectory(directory) {
2214
- const path = join9(socketPath(directory), "..");
2215
- await mkdir6(path, { recursive: true, mode: 448 });
2216
- const stat2 = await lstat3(path);
2217
- if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
2218
- throw new Error("Unsafe daemon socket directory.");
2219
- await chmod(path, 448);
2220
- }
2221
- async function daemonRequest(directory, input, timeout = 15e3) {
2222
- RequestSchema.parse(input);
2223
- return new Promise((resolve9, reject) => {
2224
- const socket = createConnection(socketPath(directory));
2225
- socket.setEncoding("utf8");
2226
- let body = "";
2227
- let done = false;
2228
- const finish = (error, result2) => {
2229
- if (done) return;
2230
- done = true;
2231
- socket.destroy();
2232
- if (error) reject(error);
2233
- else resolve9(result2);
2234
- };
2235
- socket.setTimeout(
2236
- timeout,
2237
- () => finish(new Error("Background service did not respond. Run teammy status."))
2238
- );
2239
- socket.on("error", (e) => finish(e));
2240
- socket.on("end", () => {
2241
- if (!done)
2242
- finish(
2243
- Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
2244
- );
2245
- });
2246
- socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
2247
- socket.on("data", (chunk) => {
2248
- body += chunk.toString();
2249
- if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
2250
- if (!body.includes("\n")) return;
2251
- try {
2252
- const response = JSON.parse(body.split("\n")[0]);
2253
- if (response.ok) finish(void 0, response.result);
2254
- else finish(new Error(response.error ?? "Background operation failed."));
2255
- } catch (e) {
2256
- finish(e);
2257
- }
2258
- });
2259
- });
2260
- }
2261
- async function runningDaemon(directory) {
2262
- try {
2263
- return await daemonRequest(directory, { method: "ping" }, 1e3);
2264
- } catch (error) {
2265
- if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
2266
- String(error.code)
2267
- ))
2268
- return null;
2269
- throw error;
2270
- }
2271
- }
2272
-
2273
- // apps/client/runtime/service.ts
2274
- import { execFile as execFile2 } from "node:child_process";
2275
- import { promisify as promisify2 } from "node:util";
2276
- import { homedir as homedir2 } from "node:os";
2277
- import { dirname as dirname5, join as join10, resolve as resolve4 } from "node:path";
2278
- import { mkdir as mkdir7, readFile as readFile2, writeFile as writeFile2, rename as rename3 } from "node:fs/promises";
2279
- import { setTimeout as setTimeout3 } from "node:timers/promises";
2280
- var execute = promisify2(execFile2);
2281
- var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
2282
- var xml = (s) => s.replace(
2283
- /[<>&"']/g,
2284
- (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]
2285
- );
2286
- var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
2287
- function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir2()) {
2288
- for (const value of [directory, executable, entry])
2289
- if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
2290
- const id = `com.teammy.client.${rootKey(directory)}`;
2291
- const args = [executable, entry, "_daemon", "--state-dir", directory];
2292
- if (platform === "darwin")
2293
- return {
2294
- id,
2295
- path: join10(userHome, "Library", "LaunchAgents", id + ".plist"),
2296
- content: `<?xml version="1.0" encoding="UTF-8"?>
2297
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2298
- <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>
2299
- `
2300
- };
2301
- if (platform === "linux")
2302
- return {
2303
- id: id + ".service",
2304
- path: join10(userHome, ".config", "systemd", "user", id + ".service"),
2305
- content: `[Unit]
2306
- Description=Teammy background synchronization
2307
- [Service]
2308
- Type=simple
2309
- ExecStart=${args.map(unit).join(" ")}
2310
- Restart=always
2311
- RestartSec=30
2312
- UMask=0077
2313
- StandardOutput=null
2314
- StandardError=journal
2315
- [Install]
2316
- WantedBy=default.target
2317
- `
2318
- };
2319
- throw new Error(
2320
- "Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
2321
- );
2322
- }
2323
- async function checkServiceSupport() {
2324
- try {
2325
- if (process.platform === "darwin")
2326
- await exec2("launchctl", ["print", `gui/${process.getuid()}`], {
2327
- maxBuffer: 4 * 1024 * 1024
2328
- });
2329
- else if (process.platform === "linux") await exec2("systemctl", ["--user", "show-environment"]);
2330
- else throw new Error("unsupported");
2331
- } catch {
2332
- throw new Error(
2333
- "A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
2334
- );
2335
- }
2336
- }
2337
- async function bootstrapLaunchAgent(path) {
2338
- const deadline = Date.now() + 1e4;
2339
- for (; ; ) {
2340
- try {
2341
- await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
2342
- return;
2343
- } catch (error) {
2344
- if (error.code !== 5 || Date.now() >= deadline) throw error;
2345
- await setTimeout3(250);
2346
- }
2347
- }
2348
- }
2349
- async function ensureDaemon(directory, version) {
2350
- const definition = serviceDefinition(directory, process.execPath, resolve4(process.argv[1]));
2351
- let old = "";
2352
- try {
2353
- old = await readFile2(definition.path, "utf8");
2354
- } catch (e) {
2355
- if (e.code !== "ENOENT") throw e;
2356
- }
2357
- const alive = await runningDaemon(directory);
2358
- if (alive && old === definition.content && alive.version === version) return;
2359
- await checkServiceSupport();
2360
- await mkdir7(dirname5(definition.path), { recursive: true });
2361
- if (old !== definition.content) {
2362
- const temp = definition.path + `.${process.pid}.tmp`;
2363
- await writeFile2(temp, definition.content, { mode: 384 });
2364
- await rename3(temp, definition.path);
2365
- }
2366
- if (process.platform === "darwin") {
2367
- const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
2368
- let loaded = false;
2369
- try {
2370
- await exec2("launchctl", ["print", target]);
2371
- loaded = true;
2372
- } catch {
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();
2373
3623
  }
2374
- if (loaded && (old !== definition.content || alive?.version !== version)) {
2375
- await exec2("launchctl", ["bootout", target]);
2376
- 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
+ }
2377
3633
  }
2378
- if (!loaded) await bootstrapLaunchAgent(definition.path);
2379
- await exec2("launchctl", ["kickstart", target]);
2380
- } else {
2381
- await exec2("systemctl", ["--user", "daemon-reload"]);
2382
- await exec2("systemctl", ["--user", "enable", definition.id]);
2383
- await exec2("systemctl", [
2384
- "--user",
2385
- old !== definition.content || alive?.version !== version ? "restart" : "start",
2386
- definition.id
2387
- ]);
2388
- }
2389
- const deadline = Date.now() + 15e3;
2390
- while (Date.now() < deadline) {
2391
- if ((await runningDaemon(directory))?.version === version) return;
2392
- 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();
2393
3642
  }
2394
- throw new Error(
2395
- "Background service did not start. Run teammy logs and check the user service manager."
2396
- );
2397
- }
2398
- async function wakeDaemon(directory, version) {
2399
- await ensureDaemon(directory, version);
2400
- await daemonRequest(directory, { method: "wake" });
2401
3643
  }
2402
3644
 
2403
3645
  // apps/client/output.ts
@@ -2417,15 +3659,94 @@ function json(value) {
2417
3659
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
2418
3660
  }
2419
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
+
2420
3741
  // apps/client/client-command.ts
2421
3742
  var locationOptions = {
2422
- root: { type: "string", default: resolve5("workspaces") },
2423
- "state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") },
3743
+ root: { type: "string", default: resolve10("workspaces") },
3744
+ "state-dir": { type: "string", default: join18(homedir5(), ".teammy", "client") },
2424
3745
  json: { type: "boolean" },
2425
3746
  help: { type: "boolean", short: "h" }
2426
3747
  };
2427
3748
  async function runClientCommand(command, args, version) {
2428
- const { values } = parseArgs2({
3749
+ const { values } = parseArgs3({
2429
3750
  args,
2430
3751
  options: {
2431
3752
  ...locationOptions,
@@ -2445,54 +3766,16 @@ async function runClientCommand(command, args, version) {
2445
3766
  );
2446
3767
  return 0;
2447
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
+ );
2448
3775
  const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
3776
+ await prepareClient(directory, version, { resume: !values["no-start"] });
2449
3777
  const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
2450
3778
  try {
2451
- if (command === "logs") {
2452
- const entries = log.tail(values.all ? void 0 : root);
2453
- if (values.json) json(entries);
2454
- else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
2455
- else
2456
- for (const item of entries)
2457
- process.stdout.write(
2458
- `${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
2459
- `
2460
- );
2461
- return 0;
2462
- }
2463
- if (command === "status") {
2464
- const binding = state.binding(root);
2465
- if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
2466
- const daemon = await runningDaemon(directory);
2467
- const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
2468
- const { token: _, ...publicBinding } = binding;
2469
- if (values.json)
2470
- json({
2471
- ...current,
2472
- binding: publicBinding,
2473
- control: readControlStatus(state, root),
2474
- daemon,
2475
- logPath: log.path
2476
- });
2477
- else {
2478
- const status = current.status;
2479
- 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";
2480
- result(
2481
- title,
2482
- new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
2483
- );
2484
- if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
2485
- else if (status?.lastSuccessAt)
2486
- process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
2487
- `);
2488
- if (status?.pendingFiles || status?.pendingDownloads)
2489
- process.stdout.write(
2490
- ` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
2491
- `
2492
- );
2493
- }
2494
- return 0;
2495
- }
2496
3779
  const existing = state.binding(root);
2497
3780
  const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
2498
3781
  if (!url) throw new Error("--url is required.");
@@ -2523,12 +3806,16 @@ async function runClientCommand(command, args, version) {
2523
3806
  tokenFile: values["token-file"],
2524
3807
  reconnect: values.reconnect
2525
3808
  });
3809
+ const collection = await setupSessions(state, root, directory, { enable: true });
3810
+ for (const issue of collection.issues) log.write("sessions.setup.failed", root, issue);
2526
3811
  completePreparation(state, root);
2527
3812
  runtime.enable(root, !values["no-start"]);
2528
3813
  log.write("workspace.connected", root);
2529
3814
  if (!values["no-start"]) await wakeDaemon(directory, version);
2530
- if (values.json) json({ ...connected, syncEnabled: !values["no-start"] });
3815
+ if (values.json) json({ ...connected, collection, syncEnabled: !values["no-start"] });
2531
3816
  else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
3817
+ if (collection.issues.length && !values.json)
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");
2532
3819
  return 0;
2533
3820
  } catch (e) {
2534
3821
  log.write(`${command}.failed`, root, diagnostic(e));
@@ -2539,18 +3826,18 @@ async function runClientCommand(command, args, version) {
2539
3826
  }
2540
3827
 
2541
3828
  // apps/client/files/synchronizer.ts
2542
- import { randomUUID as randomUUID6 } from "node:crypto";
3829
+ import { randomUUID as randomUUID7 } from "node:crypto";
2543
3830
  import { watch } from "node:fs";
2544
- import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
2545
- import { join as join13, 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";
2546
3833
  import { performance } from "node:perf_hooks";
2547
3834
 
2548
3835
  // packages/storage/local-blob-store.ts
2549
- import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
2550
- import { link, mkdir as mkdir8, open as open4, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
2551
- import { dirname as dirname6, join as join12, resolve as resolve6 } 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";
2552
3839
  function hash(content) {
2553
- return createHash8("sha256").update(content).digest("hex");
3840
+ return createHash9("sha256").update(content).digest("hex");
2554
3841
  }
2555
3842
  function hasCode(error, code) {
2556
3843
  return error instanceof Error && "code" in error && error.code === code;
@@ -2566,14 +3853,14 @@ var BlobIntegrityError = class extends Error {
2566
3853
  var LocalBlobStore = class {
2567
3854
  root;
2568
3855
  constructor(root) {
2569
- this.root = resolve6(root);
3856
+ this.root = resolve11(root);
2570
3857
  }
2571
3858
  async put(content) {
2572
3859
  const bytes = Buffer.from(content);
2573
3860
  const sha256 = hash(bytes);
2574
3861
  const target = this.pathFor(sha256);
2575
- await mkdir8(dirname6(target), { recursive: true, mode: 448 });
2576
- const temporary = `${target}.${randomUUID5()}.tmp`;
3862
+ await mkdir11(dirname10(target), { recursive: true, mode: 448 });
3863
+ const temporary = `${target}.${randomUUID6()}.tmp`;
2577
3864
  const file = await open4(temporary, "wx", 384);
2578
3865
  try {
2579
3866
  try {
@@ -2589,7 +3876,7 @@ var LocalBlobStore = class {
2589
3876
  await this.get(sha256);
2590
3877
  }
2591
3878
  if (process.platform !== "win32") {
2592
- let directory = dirname6(target);
3879
+ let directory = dirname10(target);
2593
3880
  for (; ; ) {
2594
3881
  const handle = await open4(directory, "r");
2595
3882
  try {
@@ -2597,8 +3884,8 @@ var LocalBlobStore = class {
2597
3884
  } finally {
2598
3885
  await handle.close();
2599
3886
  }
2600
- if (directory === dirname6(this.root)) break;
2601
- directory = dirname6(directory);
3887
+ if (directory === dirname10(this.root)) break;
3888
+ directory = dirname10(directory);
2602
3889
  }
2603
3890
  }
2604
3891
  } finally {
@@ -2610,7 +3897,7 @@ var LocalBlobStore = class {
2610
3897
  const target = this.pathFor(sha256);
2611
3898
  let content;
2612
3899
  try {
2613
- content = await readFile3(target);
3900
+ content = await readFile6(target);
2614
3901
  } catch (error) {
2615
3902
  if (hasCode(error, "ENOENT")) return void 0;
2616
3903
  throw error;
@@ -2620,7 +3907,7 @@ var LocalBlobStore = class {
2620
3907
  }
2621
3908
  pathFor(sha256) {
2622
3909
  if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
2623
- return join12(this.root, "sha256", sha256.slice(0, 2), sha256);
3910
+ return join19(this.root, "sha256", sha256.slice(0, 2), sha256);
2624
3911
  }
2625
3912
  };
2626
3913
 
@@ -2638,14 +3925,17 @@ var PublicationQueue = class {
2638
3925
  const pending = this.state.pending(root);
2639
3926
  if (!pending || pending.retryAt > now) return false;
2640
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
+ }
2641
3933
  const hashes = [
2642
3934
  ...new Set(
2643
- pending.batch.changes.flatMap(
2644
- (change) => change.kind === "upsert" ? [change.sha256] : []
2645
- )
3935
+ pending.batch.changes.slice(pending.nextIndex, end).flatMap((change) => change.kind === "upsert" ? [change.sha256] : [])
2646
3936
  )
2647
3937
  ];
2648
- const { missing: missing2 } = await this.transport.missingBlobs(hashes);
3938
+ const { missing: missing2 } = hashes.length ? await this.transport.missingBlobs(hashes) : { missing: [] };
2649
3939
  for (const hash2 of missing2) {
2650
3940
  if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
2651
3941
  const content = await this.blobs.get(hash2);
@@ -2654,9 +3944,12 @@ var PublicationQueue = class {
2654
3944
  if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
2655
3945
  throw new Error("Server acknowledged different content.");
2656
3946
  }
3947
+ this.state.uploaded(root, pending.batch.operationId, end);
3948
+ if (end < pending.batch.changes.length) return false;
2657
3949
  this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
2658
3950
  return true;
2659
3951
  } catch (error) {
3952
+ if (error instanceof Error && error.name === "AbortError") throw error;
2660
3953
  const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
2661
3954
  this.state.postpone(
2662
3955
  root,
@@ -2680,17 +3973,17 @@ var FileSynchronizer = class _FileSynchronizer {
2680
3973
  if (!binding) throw new Error("Connect this root before synchronizing.");
2681
3974
  this.binding = binding;
2682
3975
  this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2683
- this.blobs = new LocalBlobStore(join13(stateDirectory, "blobs"));
3976
+ this.blobs = new LocalBlobStore(join20(stateDirectory, "blobs"));
2684
3977
  this.projectScope = options.scope === "project";
2685
3978
  this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
2686
3979
  this.publication = new FilePublicationState(state.db, options.scope);
2687
3980
  this.queue = new PublicationQueue(this.publication, this.blobs, {
2688
3981
  missingBlobs: (hashes) => this.client.missingBlobs(hashes),
2689
3982
  uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
2690
- 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)
2691
3984
  });
2692
3985
  this.index = new SyncState(state, root, options.scope);
2693
- this.ownRoot = this.projectScope ? root : join13(root, binding.memberId);
3986
+ this.ownRoot = this.projectScope ? root : join20(root, binding.memberId);
2694
3987
  this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
2695
3988
  const started = performance.now();
2696
3989
  const epoch = Date.now();
@@ -2720,14 +4013,16 @@ var FileSynchronizer = class _FileSynchronizer {
2720
4013
  watchIssues = [];
2721
4014
  lastScan = -Infinity;
2722
4015
  busy = false;
4016
+ capable = false;
2723
4017
  stopped = false;
2724
4018
  abort = new AbortController();
2725
4019
  async checkCapabilities() {
2726
4020
  const info = await this.client.serverInfo();
2727
- if (!["identity", "blobs", "file-revisions"].every(
4021
+ if (!["identity", "blobs", "file-revisions", "atomic-file-publications"].every(
2728
4022
  (capability) => info.capabilities.includes(capability)
2729
4023
  ))
2730
- 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;
2731
4026
  }
2732
4027
  setSettleMs(value) {
2733
4028
  if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
@@ -2755,6 +4050,7 @@ var FileSynchronizer = class _FileSynchronizer {
2755
4050
  };
2756
4051
  try {
2757
4052
  try {
4053
+ if (!this.capable) await this.checkCapabilities();
2758
4054
  if (this.projectScope) {
2759
4055
  try {
2760
4056
  this.sharedConfig = await this.client.sharedFilesConfig();
@@ -2814,11 +4110,10 @@ var FileSynchronizer = class _FileSynchronizer {
2814
4110
  }
2815
4111
  const blocked = await this.refreshDirty(report.issues);
2816
4112
  await this.audit(report.issues);
2817
- report.published = await this.queue.flush(this.root, this.clock());
2818
4113
  if (!this.publication.pending(this.root) && !this.stopped) {
2819
4114
  await this.stage(blocked, report.issues, settleMs);
2820
- report.published = await this.queue.flush(this.root, this.clock()) || report.published;
2821
4115
  }
4116
+ report.published = await this.queue.flush(this.root, this.clock());
2822
4117
  } catch (error) {
2823
4118
  report.issues.push(`publish: ${message(error)}`);
2824
4119
  }
@@ -2862,7 +4157,7 @@ var FileSynchronizer = class _FileSynchronizer {
2862
4157
  throw new Error("Manifest changed version during pagination.");
2863
4158
  revision = page.revision;
2864
4159
  files.push(...page.files);
2865
- if (page.nextCursor === null) return { revision, files };
4160
+ if (page.nextCursor === null) return { revision, complete: page.complete, files };
2866
4161
  if (page.nextCursor === after || files.length > 1e5)
2867
4162
  throw new Error("Manifest pagination did not make bounded progress.");
2868
4163
  after = page.nextCursor;
@@ -2889,10 +4184,12 @@ var FileSynchronizer = class _FileSynchronizer {
2889
4184
  }
2890
4185
  }
2891
4186
  const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
2892
- await mkdir9(ownerPath).catch((error) => {
4187
+ await mkdir12(ownerPath).catch((error) => {
2893
4188
  if (error.code !== "EEXIST") throw error;
2894
4189
  });
2895
- this.publication.initializePublication(this.root, manifest.revision, manifest.files);
4190
+ this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
4191
+ complete: manifest.complete
4192
+ });
2896
4193
  }
2897
4194
  listen() {
2898
4195
  if (this.options.watch === false || this.watcher || this.stopped) return;
@@ -2902,7 +4199,7 @@ var FileSynchronizer = class _FileSynchronizer {
2902
4199
  this.lastScan = -Infinity;
2903
4200
  return;
2904
4201
  }
2905
- const path = filename.toString().split(sep4).join("/");
4202
+ const path = filename.toString().split(sep5).join("/");
2906
4203
  if (!this.excludes(path)) this.index.event(path, this.clock());
2907
4204
  });
2908
4205
  this.watcher.on("error", (error) => {
@@ -2926,6 +4223,7 @@ var FileSynchronizer = class _FileSynchronizer {
2926
4223
  }
2927
4224
  }
2928
4225
  issues.push(...scan.issues);
4226
+ return scan.complete;
2929
4227
  }
2930
4228
  async refreshDirty(issues) {
2931
4229
  const blocked = /* @__PURE__ */ new Set();
@@ -2966,73 +4264,80 @@ var FileSynchronizer = class _FileSynchronizer {
2966
4264
  }
2967
4265
  }
2968
4266
  async stage(blocked, issues, settleMs) {
2969
- const ready = this.index.index(true).filter(
2970
- (row) => !this.excludes(row.path) && !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
2971
- );
2972
- 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;
2973
4277
  const baseline = this.publication.ownerFiles(this.root);
2974
4278
  const planned = new Map(baseline);
2975
4279
  const changes = [];
2976
4280
  const snapshots = [];
2977
- let bytes = 0;
2978
4281
  ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
2979
4282
  for (const row of ready) {
2980
- if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
4283
+ if (this.stopped) return;
2981
4284
  try {
2982
4285
  if (row.signature === null) {
2983
- const current = await metadata(this.ownRoot, row.path);
2984
- if (current && !current.directory) {
2985
- this.index.event(row.path, this.clock());
2986
- continue;
2987
- }
2988
4286
  if (baseline.has(row.path) && row.seenLocal) {
2989
4287
  changes.push({ kind: "delete", path: row.path });
2990
4288
  planned.delete(row.path);
2991
- } else {
2992
- this.index.clean(row.path, row.generation, null, this.clock());
2993
- continue;
2994
4289
  }
2995
4290
  snapshots.push({ path: row.path, generation: row.generation, hash: null });
2996
4291
  continue;
2997
4292
  }
2998
- const key = row.path.toLowerCase();
2999
- const collision = [...planned.keys()].some(
3000
- (path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
3001
- );
3002
- if (collision) {
3003
- issues.push(
3004
- `${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
3005
- );
3006
- continue;
3007
- }
3008
4293
  const local = await readStable(this.ownRoot, row.path);
3009
- bytes += local.bytes.length;
3010
4294
  if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
3011
4295
  this.index.event(row.path, this.clock());
3012
- continue;
4296
+ return;
3013
4297
  }
3014
- if (baseline.get(row.path)?.sha256 === local.sha256) {
3015
- this.index.clean(row.path, row.generation, local.sha256, this.clock());
3016
- 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);
3017
4302
  }
3018
- const ref = await this.blobs.put(local.bytes);
3019
- changes.push({ kind: "upsert", path: row.path, ...ref });
3020
- planned.set(row.path, ref);
3021
4303
  snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
3022
4304
  } catch (error) {
3023
4305
  this.index.event(row.path, this.clock());
3024
4306
  issues.push(`${row.path}: ${message(error)}`);
4307
+ return;
3025
4308
  }
3026
4309
  }
3027
- if (!changes.length) return;
3028
- const rootStat = await lstat4(this.ownRoot);
3029
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
3030
- throw new Error("Author directory disappeared before publication.");
3031
- this.publication.enqueue(this.root, {
3032
- operationId: randomUUID6(),
3033
- parentRevision: this.publication.publication(this.root).revision,
3034
- changes
3035
- });
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
+ }
3036
4341
  for (const snapshot of snapshots)
3037
4342
  this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
3038
4343
  }
@@ -3075,7 +4380,7 @@ var FileSynchronizer = class _FileSynchronizer {
3075
4380
  }
3076
4381
  const job = this.index.job(owner);
3077
4382
  const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
3078
- await mkdir9(ownerRoot).catch((error) => {
4383
+ await mkdir12(ownerRoot).catch((error) => {
3079
4384
  if (error.code !== "EEXIST") throw error;
3080
4385
  });
3081
4386
  const ownerStat = await lstat4(ownerRoot);
@@ -3130,24 +4435,27 @@ function message(error) {
3130
4435
  }
3131
4436
 
3132
4437
  // apps/client/sessions/synchronizer.ts
3133
- import { randomUUID as randomUUID7 } from "node:crypto";
3134
- import { join as join15 } 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";
3135
4441
 
3136
4442
  // packages/protocol/session-key.ts
3137
- import { createHash as createHash9 } from "node:crypto";
4443
+ import { createHash as createHash10 } from "node:crypto";
3138
4444
  function sessionKey(agent, nativeId) {
3139
- return createHash9("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
4445
+ return createHash10("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
3140
4446
  }
3141
4447
 
3142
4448
  // apps/client/sessions/source.ts
3143
- import { open as open5, stat, realpath as realpath5 } from "node:fs/promises";
3144
- import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
4449
+ import { createReadStream, realpathSync } from "node:fs";
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";
4452
+ import { createInterface as createInterface3 } from "node:readline";
3145
4453
  import * as zlib from "node:zlib";
3146
4454
 
3147
4455
  // apps/client/sessions/cursor.ts
3148
- import { createHash as createHash10 } from "node:crypto";
3149
- import { basename as basename2, dirname as dirname7, resolve as resolve7 } from "node:path";
3150
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
4456
+ import { createHash as createHash11 } from "node:crypto";
4457
+ import { basename as basename3, dirname as dirname11, resolve as resolve12 } from "node:path";
4458
+ import { DatabaseSync as DatabaseSync6 } from "node:sqlite";
3151
4459
  import { fileURLToPath } from "node:url";
3152
4460
  function fields(bytes) {
3153
4461
  const data = Buffer.from(bytes);
@@ -3184,34 +4492,31 @@ function fields(bytes) {
3184
4492
  }
3185
4493
  return result2;
3186
4494
  }
3187
- function readCursor(path, projectDir) {
3188
- const db = new DatabaseSync2(path, { readOnly: true });
4495
+ function readCursor(path, projectDir, checkCwd) {
4496
+ const db = new DatabaseSync6(path, { readOnly: true });
3189
4497
  try {
3190
4498
  db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
3191
4499
  if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
3192
4500
  throw new Error("Unsupported Cursor CLI store version (expected 1).");
3193
- 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();
3194
4502
  const hex = row?.value;
3195
4503
  if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
3196
4504
  throw new Error("Invalid Cursor metadata.");
3197
4505
  const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
3198
- if (typeof meta.agentId !== "string" || meta.agentId !== basename2(dirname7(path)) || meta.subagentInfo != null)
4506
+ if (typeof meta.agentId !== "string" || meta.agentId !== basename3(dirname11(path)) || meta.subagentInfo != null)
3199
4507
  throw new Error("Invalid or delegated Cursor session identity.");
3200
- let budget = 64 * 1024 * 1024;
3201
4508
  const blob = (id) => {
3202
4509
  if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
3203
- const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
3204
- if (!(value instanceof Uint8Array))
3205
- 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.");
3206
4512
  const bytes = Buffer.from(value);
3207
- budget -= bytes.length;
3208
- if (createHash10("sha256").update(bytes).digest("hex") !== id)
4513
+ if (createHash11("sha256").update(bytes).digest("hex") !== id)
3209
4514
  throw new Error("Cursor blob failed integrity verification.");
3210
4515
  return bytes;
3211
4516
  };
3212
4517
  const root = fields(blob(String(meta.latestRootBlobId)));
3213
- const bucket = basename2(dirname7(dirname7(path)));
3214
- const md5 = (cwd2) => createHash10("md5").update(resolve7(cwd2)).digest("hex");
4518
+ const bucket = basename3(dirname11(dirname11(path)));
4519
+ const md5 = (cwd2) => createHash11("md5").update(resolve12(cwd2)).digest("hex");
3215
4520
  let cwd = md5(projectDir) === bucket ? projectDir : void 0;
3216
4521
  for (const uri of root.get(9) ?? []) {
3217
4522
  try {
@@ -3224,11 +4529,11 @@ function readCursor(path, projectDir) {
3224
4529
  }
3225
4530
  }
3226
4531
  if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
4532
+ checkCwd?.(cwd);
3227
4533
  const refs = [];
3228
4534
  for (const archive of root.get(13) ?? root.get(11) ?? [])
3229
- refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
3230
- refs.push(...root.get(1) ?? []);
3231
- 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);
3232
4537
  const messages = [];
3233
4538
  const counts = /* @__PURE__ */ new Map();
3234
4539
  for (const ref of refs) {
@@ -3246,7 +4551,7 @@ function readCursor(path, projectDir) {
3246
4551
  if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
3247
4552
  }
3248
4553
  if (!text.trim()) continue;
3249
- const id = createHash10("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
4554
+ const id = createHash11("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
3250
4555
  const count = (counts.get(id) ?? 0) + 1;
3251
4556
  counts.set(id, count);
3252
4557
  messages.push({
@@ -3270,9 +4575,14 @@ function readCursor(path, projectDir) {
3270
4575
  }
3271
4576
 
3272
4577
  // apps/client/sessions/source.ts
3273
- var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
4578
+ function within(project, cwd) {
4579
+ const rel = relative5(project, cwd);
4580
+ return rel !== ".." && !rel.startsWith(`..${sep6}`) && !isAbsolute5(rel);
4581
+ }
4582
+ var cursorEncoded = (cwd) => cwd.replace(/^[/\\]+/, "").replace(/[/\\]/g, "-");
4583
+ var cursorDatabase = (path) => basename4(path) === "store.db";
3274
4584
  async function sourceSignature(path, agent) {
3275
- const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
4585
+ const paths = agent === "cursor" && cursorDatabase(path) ? [path, `${path}-wal`] : [path];
3276
4586
  const signatures = [];
3277
4587
  for (const file of paths) {
3278
4588
  try {
@@ -3293,60 +4603,142 @@ async function sourceSignature(path, agent) {
3293
4603
  }
3294
4604
  return JSON.stringify(signatures);
3295
4605
  }
4606
+ async function cursorScope(path, project) {
4607
+ let container = dirname12(path);
4608
+ while (basename4(container) !== "agent-transcripts" && dirname12(container) !== container)
4609
+ container = dirname12(container);
4610
+ if (basename4(container) !== "agent-transcripts")
4611
+ throw new Error("Cursor transcript workspace cannot be verified.");
4612
+ const encoded = basename4(dirname12(container));
4613
+ const matches = [];
4614
+ const visit = async (directory) => {
4615
+ const prefix = cursorEncoded(directory);
4616
+ if (encoded === prefix) {
4617
+ matches.push(directory);
4618
+ return;
4619
+ }
4620
+ if (!encoded.startsWith(prefix + "-")) return;
4621
+ for (const entry of await readdir2(directory, { withFileTypes: true })) {
4622
+ if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(join21(directory, entry.name));
4623
+ }
4624
+ };
4625
+ await visit(project);
4626
+ if (matches.length !== 1)
4627
+ throw new Error("Session cwd is outside the registered project or ambiguous.");
4628
+ return matches[0];
4629
+ }
3296
4630
  async function readSource(agent, path, projectDir) {
3297
4631
  const signature2 = await sourceSignature(path, agent);
3298
- let result2;
3299
- if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
3300
- else {
3301
- const handle = await open5(path, "r");
3302
- let bytes;
3303
- try {
3304
- const size = (await handle.stat()).size;
3305
- if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
3306
- bytes = Buffer.alloc(size + 1);
3307
- let offset = 0;
3308
- while (offset < bytes.length) {
3309
- const read = await handle.read(bytes, offset, bytes.length - offset, offset);
3310
- if (!read.bytesRead) break;
3311
- offset += read.bytesRead;
3312
- }
3313
- bytes = bytes.subarray(0, offset);
3314
- if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
3315
- } finally {
3316
- await handle.close();
4632
+ const project = await realpath8(projectDir);
4633
+ if (agent === "cursor" && cursorDatabase(path)) {
4634
+ const snapshot2 = readCursor(path, projectDir, (cwd) => {
4635
+ if (!within(project, realpathSync(cwd)))
4636
+ throw new Error("Session cwd is outside the registered project.");
4637
+ });
4638
+ return { snapshot: snapshot2, incomplete: false, skipped: 0, signature: signature2 };
4639
+ }
4640
+ const cursor = agent === "cursor" ? { nativeId: basename4(path, ".jsonl"), cwd: await cursorScope(path, projectDir) } : void 0;
4641
+ const normalizer = new JsonlNormalizer(agent, cursor);
4642
+ const stream = createReadStream(path, {
4643
+ end: Math.max(0, Number(JSON.parse(signature2)[0][2]) - 1)
4644
+ });
4645
+ let input = stream;
4646
+ if (agent === "dsh" && path.endsWith(".zstd")) {
4647
+ const decompress = zlib.createZstdDecompress;
4648
+ if (!decompress) {
4649
+ stream.destroy();
4650
+ throw new Error("Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+).");
3317
4651
  }
3318
- if (agent === "dsh" && path.endsWith(".zstd")) {
3319
- const decompress = zlib.zstdDecompressSync;
3320
- if (!decompress)
3321
- throw new Error(
3322
- "Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
3323
- );
3324
- bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
4652
+ const decoder = decompress();
4653
+ stream.on("error", (e) => decoder.destroy(e));
4654
+ input = stream.pipe(decoder);
4655
+ }
4656
+ const lines = createInterface3({ input, crlfDelay: Infinity });
4657
+ const checkedCwds = /* @__PURE__ */ new Set();
4658
+ let line = 0, skipped = 0, lastBad = false;
4659
+ try {
4660
+ for await (const text of lines) {
4661
+ line++;
4662
+ if (!text.trim()) continue;
4663
+ const row = parseRecord(text);
4664
+ lastBad = !row;
4665
+ if (!row) {
4666
+ skipped++;
4667
+ continue;
4668
+ }
4669
+ const cwd = row.cwd ?? (row.type === "session_meta" ? object(row.payload).cwd : void 0);
4670
+ if (typeof cwd === "string" && !checkedCwds.has(cwd)) {
4671
+ const actual = await realpath8(cwd).catch((error) => {
4672
+ if (error.code === "ENOENT" && !within(project, cwd))
4673
+ throw new Error("Session cwd is outside the registered project.");
4674
+ throw error;
4675
+ });
4676
+ if (!within(project, actual))
4677
+ throw new Error("Session cwd is outside the registered project.");
4678
+ checkedCwds.add(cwd);
4679
+ }
4680
+ normalizer.consume(row, line);
3325
4681
  }
3326
- result2 = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
4682
+ } finally {
4683
+ lines.close();
4684
+ stream.destroy();
4685
+ if (input !== stream) input.destroy();
3327
4686
  }
3328
- if (signature2 !== await sourceSignature(path, agent))
4687
+ const before = JSON.parse(signature2)[0];
4688
+ const after = JSON.parse(await sourceSignature(path, agent))[0];
4689
+ if (before[0] !== after[0] || before[1] !== after[1] || Number(after[2]) < Number(before[2]) || before[2] === after[2] && before.join() !== after.join())
3329
4690
  throw new Error("Session source changed while reading; retry next cycle.");
3330
- const cwd = await realpath5(result2.snapshot.cwd);
3331
- const project = await realpath5(projectDir);
3332
- const rel = relative4(project, cwd);
3333
- if (rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute4(rel))
4691
+ const snapshot = normalizer.finish();
4692
+ if (!within(project, await realpath8(snapshot.cwd)))
3334
4693
  throw new Error("Session cwd is outside the registered project.");
3335
- return { ...result2, signature: signature2 };
4694
+ return { snapshot, incomplete: lastBad, skipped, signature: signature2 };
3336
4695
  }
3337
4696
 
3338
4697
  // apps/client/sessions/discovery.ts
3339
4698
  import { opendir as opendir2 } from "node:fs/promises";
3340
- import { join as join14 } 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
3341
4733
  async function* candidates(directory, agent) {
3342
4734
  const queue = [directory];
3343
4735
  while (queue.length) {
3344
4736
  const current = queue.shift();
3345
4737
  const dir = await opendir2(current);
3346
4738
  for await (const entry of dir) {
3347
- const path = join14(current, entry.name);
4739
+ const path = join22(current, entry.name);
3348
4740
  if (entry.isDirectory()) queue.push(path);
3349
- if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
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")))
3350
4742
  yield path;
3351
4743
  else yield null;
3352
4744
  }
@@ -3358,10 +4750,12 @@ var SessionDiscovery = class {
3358
4750
  }
3359
4751
  state;
3360
4752
  scans = /* @__PURE__ */ new Map();
4753
+ pending = false;
3361
4754
  async cycle() {
4755
+ this.pending = false;
3362
4756
  const issues = [];
3363
4757
  let entries = 500;
3364
- let reads = 5;
4758
+ const started = performance2.now();
3365
4759
  const configs = this.state.directories();
3366
4760
  for (const [directory, scan] of this.scans) {
3367
4761
  if (!configs.some((config) => config.directory === directory)) {
@@ -3377,7 +4771,7 @@ var SessionDiscovery = class {
3377
4771
  }
3378
4772
  if (scan.nextAt > Date.now()) continue;
3379
4773
  try {
3380
- while (entries > 0 && reads > 0) {
4774
+ while (entries > 0 && performance2.now() - started < 1e3) {
3381
4775
  entries--;
3382
4776
  const entry = await scan.iterator.next();
3383
4777
  if (entry.done) {
@@ -3390,35 +4784,42 @@ var SessionDiscovery = class {
3390
4784
  const signature2 = await sourceSignature(path, config.agent);
3391
4785
  const db = this.state.state.db;
3392
4786
  if (db.prepare(
3393
- "SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
3394
- ).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]))
3395
4789
  continue;
3396
- reads--;
3397
4790
  try {
3398
- const result2 = await readSource(config.agent, path, config.projectDir);
3399
- if (result2.incomplete) continue;
4791
+ const result2 = await sessionWorkQueue.run(
4792
+ () => readSource(config.agent, path, config.projectDir)
4793
+ );
4794
+ if (!result2.snapshot.messages.length) continue;
3400
4795
  this.state.track(config.agent, path, config.projectDir);
3401
4796
  } catch (error) {
3402
4797
  const message2 = error instanceof Error ? error.message : "Discovery failed.";
3403
4798
  if (!message2.includes("outside the registered project"))
3404
4799
  issues.push(`${path}: ${message2}`);
3405
- if (!message2.includes("outside the registered project")) continue;
4800
+ if (error.code === "ENOENT" || /locked|busy|changed while reading/i.test(message2))
4801
+ continue;
3406
4802
  }
3407
4803
  db.prepare(
3408
- "INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
3409
- ).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]);
3410
4806
  }
3411
4807
  } catch (error) {
3412
- issues.push(
3413
- `${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
3414
- );
4808
+ if (error.code !== "ENOENT")
4809
+ issues.push(
4810
+ `${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
4811
+ );
3415
4812
  await scan.iterator.return(void 0);
3416
4813
  scan.iterator = candidates(config.directory, config.agent);
3417
4814
  scan.nextAt = Date.now() + 3e5;
3418
4815
  }
3419
4816
  }
4817
+ this.pending = [...this.scans.values()].some((scan) => scan.nextAt <= Date.now());
3420
4818
  return issues;
3421
4819
  }
4820
+ async refresh() {
4821
+ for (const scan of this.scans.values()) scan.nextAt = 0;
4822
+ }
3422
4823
  async close() {
3423
4824
  for (const scan of this.scans.values()) await scan.iterator.return(void 0);
3424
4825
  }
@@ -3429,10 +4830,11 @@ var SessionSynchronizer = class {
3429
4830
  constructor(state, root, directory, signal) {
3430
4831
  this.root = root;
3431
4832
  this.directory = directory;
4833
+ this.signal = signal;
3432
4834
  const binding = state.binding(root);
3433
4835
  if (!binding) throw new Error("Connect this root before collecting sessions.");
3434
4836
  this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
3435
- this.blobs = new LocalBlobStore(join15(directory, "blobs"));
4837
+ this.blobs = new LocalBlobStore(join23(directory, "blobs"));
3436
4838
  this.index = new SessionState(state, root);
3437
4839
  this.discovery = new SessionDiscovery(this.index);
3438
4840
  }
@@ -3443,11 +4845,29 @@ var SessionSynchronizer = class {
3443
4845
  blobs;
3444
4846
  discovery;
3445
4847
  capable = false;
4848
+ passAt;
4849
+ signal;
3446
4850
  async cycle() {
3447
- const report = { published: 0, checked: 0, issues: [] };
3448
- report.issues.push(...await consumeHints(this.directory, this.root, this.index));
3449
- report.issues.push(...await this.discovery.cycle());
3450
- const sources = this.index.sources(void 0, 200);
4851
+ const report = {
4852
+ published: 0,
4853
+ checked: 0,
4854
+ pending: false,
4855
+ issues: [],
4856
+ warnings: []
4857
+ };
4858
+ report.issues.push(
4859
+ ...await consumeHints(
4860
+ this.directory,
4861
+ this.root,
4862
+ this.index,
4863
+ () => this.discovery.refresh()
4864
+ )
4865
+ );
4866
+ report.warnings.push(...await this.discovery.cycle());
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;
3451
4871
  if (sources.length && !this.capable) {
3452
4872
  const info = await this.client.serverInfo();
3453
4873
  if (!["blobs", "session-revisions"].every(
@@ -3456,48 +4876,88 @@ var SessionSynchronizer = class {
3456
4876
  throw new Error("Server does not support session publication. Upgrade the server first.");
3457
4877
  this.capable = true;
3458
4878
  }
3459
- let reads = 5;
3460
- let uploads = 5;
3461
- for (const source of sources) {
3462
- if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
3463
- report.checked++;
3464
- this.index.checked(source.path, source.lastError);
3465
- try {
3466
- if (source.pending) {
3467
- if (source.retryAt > Date.now()) {
3468
- if (source.lastError)
3469
- report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
3470
- continue;
3471
- }
3472
- uploads--;
3473
- await this.flush(source);
3474
- report.published++;
3475
- continue;
3476
- }
3477
- const signature2 = await sourceSignature(source.path, source.agent);
3478
- if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
3479
- this.index.checked(source.path);
3480
- 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++;
3481
4890
  }
3482
- reads--;
3483
- const observed = await readSource(source.agent, source.path, source.projectDir);
3484
- if (observed.incomplete)
3485
- throw new Error("Final session record is incomplete; waiting for the next save.");
3486
- if (!observed.snapshot.messages.length) {
3487
- this.index.checked(source.path);
3488
- 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;
3489
4912
  }
3490
- const key = sessionKey(source.agent, observed.snapshot.nativeId);
3491
- if (source.key && source.key !== key)
3492
- throw new Error(
3493
- "Native session identity changed at this path; register the new source explicitly."
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);
4940
+ report.warnings.push(
4941
+ `${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
3494
4942
  );
3495
- const bytes = Buffer.from(JSON.stringify(observed.snapshot));
3496
- if (bytes.length > MAX_BLOB_BYTES)
3497
- throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
3498
- const blob = await this.blobs.put(bytes);
4943
+ return;
4944
+ }
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);
3499
4959
  let revision = source.revision;
3500
- if (!source.key) {
4960
+ if (!source.key && !this.index.publication(key)) {
3501
4961
  try {
3502
4962
  const owner = this.index.state.binding(this.root).memberId;
3503
4963
  const current = await this.client.session(owner, key);
@@ -3510,27 +4970,33 @@ var SessionSynchronizer = class {
3510
4970
  if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
3511
4971
  throw error;
3512
4972
  }
3513
- this.index.baseline(source.path, key, revision);
4973
+ }
4974
+ this.index.baseline(source.path, key, revision);
4975
+ source = this.index.get(source.path);
4976
+ revision = source.revision;
4977
+ if (source.pending) return;
4978
+ if (source.agent === "cursor" && source.selectedPath && cursorDatabase(source.selectedPath) && !cursorDatabase(source.path)) {
4979
+ this.index.observed(source.path, observed.signature);
4980
+ return;
3514
4981
  }
3515
4982
  if (blob.sha256 === source.hash) {
3516
4983
  this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
3517
- continue;
4984
+ return;
3518
4985
  }
3519
4986
  this.index.stage(
3520
4987
  source.path,
3521
- { operationId: randomUUID7(), sessionKey: key, parentRevision: revision, blob },
4988
+ { operationId: randomUUID8(), sessionKey: key, parentRevision: revision, blob },
3522
4989
  observed.signature
3523
4990
  );
3524
- uploads--;
3525
4991
  await this.flush(this.index.get(source.path));
3526
4992
  report.published++;
3527
- } catch (error) {
3528
- const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
3529
- this.index.checked(source.path, message2);
3530
- report.issues.push(`${source.path}: ${message2}`);
3531
- }
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}`);
3532
4999
  }
3533
- return report;
3534
5000
  }
3535
5001
  async flush(source) {
3536
5002
  const request = source.pending;
@@ -3552,9 +5018,11 @@ var SessionSynchronizer = class {
3552
5018
  source.path,
3553
5019
  result2.revision,
3554
5020
  request.blob.sha256,
3555
- source.pendingSignature
5021
+ source.pendingSignature,
5022
+ source.pendingParserVersion
3556
5023
  );
3557
5024
  } catch (error) {
5025
+ if (this.signal?.aborted) throw error;
3558
5026
  const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
3559
5027
  this.index.postpone(
3560
5028
  source,
@@ -3670,7 +5138,13 @@ var SyncJob = class {
3670
5138
  pendingPublication: false,
3671
5139
  issues: []
3672
5140
  };
3673
- let sessions = { published: 0, checked: 0, issues: [] };
5141
+ let sessions = {
5142
+ published: 0,
5143
+ checked: 0,
5144
+ pending: false,
5145
+ issues: [],
5146
+ warnings: []
5147
+ };
3674
5148
  try {
3675
5149
  await verifyWorkspace(this.state, this.root);
3676
5150
  if (!this.controlStarted) {
@@ -3680,6 +5154,8 @@ var SyncJob = class {
3680
5154
  }
3681
5155
  if (this.files) report = await this.files.cycle();
3682
5156
  sessions = await this.sessions.cycle();
5157
+ for (const warning of sessions.warnings)
5158
+ this.log.write("sessions.partial", this.root, warning);
3683
5159
  } catch (e) {
3684
5160
  report.issues.push(diagnostic(e));
3685
5161
  }
@@ -3730,11 +5206,20 @@ var SyncJob = class {
3730
5206
  );
3731
5207
  }
3732
5208
  this.lastIssue = issueKey;
3733
- this.nextAt = now + (issue ? 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);
3734
5219
  this.runtime.save(this.root, {
3735
5220
  state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
3736
5221
  checkedAt,
3737
- lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
5222
+ lastSuccessAt: !issue && !pendingDownloads && !report.pendingPublication ? checkedAt : prior?.lastSuccessAt ?? null,
3738
5223
  pendingFiles,
3739
5224
  pendingDownloads,
3740
5225
  issue: issue || null
@@ -3780,7 +5265,7 @@ Use teammy status or teammy logs for diagnostics.
3780
5265
  `;
3781
5266
  async function runSyncCommand(args, version) {
3782
5267
  const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
3783
- const { values } = parseArgs3({
5268
+ const { values } = parseArgs4({
3784
5269
  args: action ? args.slice(1) : args,
3785
5270
  options: {
3786
5271
  ...locationOptions,
@@ -3820,16 +5305,29 @@ async function runSyncCommand(args, version) {
3820
5305
  "Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
3821
5306
  );
3822
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
+ });
3823
5312
  const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
3824
5313
  try {
3825
5314
  if (!state.binding(root)) throw new Error("Connect this workspace first.");
3826
5315
  if (action) {
3827
5316
  if (action === "on") await verifyWorkspace(state, root);
3828
5317
  runtime.enable(root, action === "on");
3829
- if (action === "on") await wakeDaemon(directory, version);
3830
- else {
3831
- if (await runningDaemon(directory))
3832
- 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
+ }
3833
5331
  const status = runtime.status(root);
3834
5332
  if (status) runtime.save(root, { ...status, state: "stopped" });
3835
5333
  log.write("sync.off_requested", root);
@@ -3890,10 +5388,60 @@ async function runSyncCommand(args, version) {
3890
5388
  }
3891
5389
  }
3892
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
+
3893
5438
  // apps/client/runtime/daemon.ts
3894
5439
  import { createServer } from "node:net";
3895
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";
3896
5443
  async function runDaemon(directory, version) {
5444
+ await prepareClient(directory, version, { daemonStartup: true });
3897
5445
  const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
3898
5446
  const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
3899
5447
  let closing = false, serial = Promise.resolve();
@@ -3958,7 +5506,15 @@ async function runDaemon(directory, version) {
3958
5506
  void (async () => {
3959
5507
  const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
3960
5508
  if (closing) throw new Error("Background service is stopping.");
3961
- 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
+ };
3962
5518
  if (input.method === "wake") {
3963
5519
  retry.clear();
3964
5520
  await reconcile();
@@ -3996,8 +5552,8 @@ async function runDaemon(directory, version) {
3996
5552
  let timer;
3997
5553
  let resolveStop = () => {
3998
5554
  };
3999
- const stopped = new Promise((resolve9) => {
4000
- resolveStop = resolve9;
5555
+ const stopped = new Promise((resolve16) => {
5556
+ resolveStop = resolve16;
4001
5557
  });
4002
5558
  const stop = () => {
4003
5559
  closing = true;
@@ -4008,14 +5564,15 @@ async function runDaemon(directory, version) {
4008
5564
  await unlink4(socketPath(directory)).catch((e) => {
4009
5565
  if (e.code !== "ENOENT") throw e;
4010
5566
  });
4011
- await new Promise((resolve9, reject) => {
5567
+ await new Promise((resolve16, reject) => {
4012
5568
  server.once("error", reject);
4013
5569
  server.listen(socketPath(directory), () => {
4014
5570
  server.off("error", reject);
4015
- resolve9();
5571
+ resolve16();
4016
5572
  });
4017
5573
  });
4018
5574
  await chmod2(socketPath(directory), 384);
5575
+ writePrivateJson(join25(directory, "runtime", "daemon.json"), { pid: process.pid, directory });
4019
5576
  process.once("SIGINT", stop);
4020
5577
  process.once("SIGTERM", stop);
4021
5578
  log.write("daemon.started", void 0, `version=${version}`);
@@ -4035,9 +5592,11 @@ async function runDaemon(directory, version) {
4035
5592
  await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
4036
5593
  await Promise.allSettled(oneShots.values());
4037
5594
  for (const socket of sockets) socket.destroy();
4038
- await new Promise((resolve9) => server.close(() => resolve9()));
5595
+ await new Promise((resolve16) => server.close(() => resolve16()));
4039
5596
  await unlink4(socketPath(directory)).catch(() => {
4040
5597
  });
5598
+ await unlink4(join25(directory, "runtime", "daemon.json")).catch(() => {
5599
+ });
4041
5600
  log.write("daemon.stopped");
4042
5601
  state.close();
4043
5602
  lock.close();
@@ -4054,10 +5613,12 @@ Usage:
4054
5613
  teammy server-info --url URL Check server connection and protocol capabilities
4055
5614
  teammy server-info --help Show connection options
4056
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
4057
5618
  teammy status [--root DIR] [--state-dir DIR]
4058
5619
  teammy sync on | off [--root DIR]
4059
5620
  teammy logs [--root DIR] Show recent diagnostic events
4060
- teammy session --help Collect, read and search Agent sessions
5621
+ teammy session --help Collect, read and search the conversation library (\u5BF9\u8BDD\u5E93)
4061
5622
 
4062
5623
  Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
4063
5624
  connect starts background sync; enabled projects resume after login.
@@ -4089,12 +5650,12 @@ async function runCli(args, version) {
4089
5650
  return 1;
4090
5651
  }
4091
5652
  if (command === "_daemon") {
4092
- const { values } = parseArgs4({
5653
+ const { values } = parseArgs6({
4093
5654
  args: args.slice(1),
4094
5655
  options: { "state-dir": { type: "string" } }
4095
5656
  });
4096
5657
  if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
4097
- const directory = resolve8(values["state-dir"]);
5658
+ const directory = resolve15(values["state-dir"]);
4098
5659
  try {
4099
5660
  await runDaemon(directory, version);
4100
5661
  return 0;
@@ -4103,9 +5664,17 @@ async function runCli(args, version) {
4103
5664
  return 1;
4104
5665
  }
4105
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
+ }
4106
5675
  if (command === "session") {
4107
5676
  try {
4108
- return await runSessionCommand(args.slice(1));
5677
+ return await runSessionCommand(args.slice(1), version);
4109
5678
  } catch (error) {
4110
5679
  if (args[1] === "hook") return 0;
4111
5680
  process.stderr.write(
@@ -4138,7 +5707,7 @@ async function runCli(args, version) {
4138
5707
  return 1;
4139
5708
  }
4140
5709
  try {
4141
- const { values } = parseArgs4({
5710
+ const { values } = parseArgs6({
4142
5711
  args: args.slice(1),
4143
5712
  options: {
4144
5713
  help: { type: "boolean", short: "h" },