@teammy/cli 0.0.7 → 0.0.9

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/main.js +592 -348
  3. package/package.json +2 -1
package/dist/main.js CHANGED
@@ -257,7 +257,7 @@ var SessionSearchSchema = z4.object({
257
257
  });
258
258
 
259
259
  // packages/transport/hub-client.ts
260
- import { z as z6 } from "zod";
260
+ import { z as z7 } from "zod";
261
261
  import { createHash } from "node:crypto";
262
262
 
263
263
  // packages/protocol/index.ts
@@ -278,6 +278,33 @@ var ErrorResponseSchema = z5.object({
278
278
  })
279
279
  });
280
280
 
281
+ // packages/protocol/shared-files.ts
282
+ import { z as z6 } from "zod";
283
+ 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) => {
286
+ const directory = path.endsWith("/");
287
+ const name2 = directory ? path.slice(0, -1) : path;
288
+ return FilePathSchema.safeParse(name2).success && !name2.includes("/") && !name2.startsWith(".") && !/^_?teammy/i.test(name2) && (directory ? name2.startsWith("_") : name2.includes("."));
289
+ }, "Use a root file (README.md) or an underscore-prefixed directory (_template/).");
290
+ var SharedFilesConfigSchema = z6.object({
291
+ maintainer: NameSchema.nullable(),
292
+ paths: z6.array(SharedPathSchema).max(30).refine((paths) => new Set(paths.map((p) => p.toLowerCase())).size === paths.length),
293
+ revision: RevisionSchema
294
+ }).strict();
295
+ var SharedFilesUpdateSchema = SharedFilesConfigSchema.omit({ revision: true }).extend({ expectedRevision: RevisionSchema }).strict();
296
+ var SharedFileCommitSchema = FileCommitSchema.safeExtend({
297
+ configRevision: RevisionSchema
298
+ });
299
+ function privateWorkspacePath(path) {
300
+ return path.split("/").some((part) => /^\.teammy(?:[-.]|$)/i.test(part));
301
+ }
302
+ function sharedPathAllowed(path, paths) {
303
+ return !privateWorkspacePath(path) && paths.some(
304
+ (entry) => entry.endsWith("/") ? path === entry.slice(0, -1) || path.startsWith(entry) : path === entry
305
+ );
306
+ }
307
+
281
308
  // packages/transport/hub-client.ts
282
309
  var HubRequestError = class extends Error {
283
310
  constructor(message2, status, code, requestId) {
@@ -340,7 +367,7 @@ var HubClient = class {
340
367
  "GET",
341
368
  `api/v1/blobs/${HashSchema.parse(hash2)}`
342
369
  );
343
- if (!response.ok) return this.decode(response, signal, z6.never());
370
+ if (!response.ok) return this.decode(response, signal, z7.never());
344
371
  const reader = response.body?.getReader();
345
372
  if (!reader) throw new Error("Server returned no content body.");
346
373
  const parts = [];
@@ -385,7 +412,7 @@ var HubClient = class {
385
412
  return this.request(
386
413
  "PUT",
387
414
  "api/v1/devices/self/status",
388
- z6.object({ ok: z6.literal(true) }),
415
+ z7.object({ ok: z7.literal(true) }),
389
416
  status
390
417
  );
391
418
  }
@@ -395,6 +422,15 @@ var HubClient = class {
395
422
  projectConfig() {
396
423
  return this.request("GET", "api/v1/project/config", ProjectConfigSchema);
397
424
  }
425
+ sharedFilesConfig() {
426
+ return this.request("GET", "api/v1/project/shared-files", SharedFilesConfigSchema);
427
+ }
428
+ commitSharedFiles(batch, configRevision) {
429
+ return this.request("POST", "api/v1/files/_project/commits", CommitResultSchema, {
430
+ ...batch,
431
+ configRevision
432
+ });
433
+ }
398
434
  commitSession(commit) {
399
435
  return this.request(
400
436
  "POST",
@@ -488,48 +524,6 @@ import { homedir } from "node:os";
488
524
  import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
489
525
  import { parseArgs } from "node:util";
490
526
 
491
- // apps/client/runtime/state.ts
492
- var runtimeMigration = `
493
- ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
494
- UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
495
- CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
496
- CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
497
- `;
498
- var RuntimeState = class {
499
- constructor(state) {
500
- this.state = state;
501
- }
502
- state;
503
- enabled(root) {
504
- return Boolean(
505
- this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
506
- );
507
- }
508
- enable(root, enabled) {
509
- if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
510
- this.state.db.prepare(
511
- "INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
512
- ).run(root, Number(enabled));
513
- }
514
- roots() {
515
- return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
516
- }
517
- status(root) {
518
- const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
519
- return row?.status_json ? JSON.parse(String(row.status_json)) : null;
520
- }
521
- save(root, status) {
522
- this.state.db.prepare(
523
- "INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
524
- ).run(root, JSON.stringify(status));
525
- }
526
- };
527
-
528
- // apps/client/state.ts
529
- import { randomUUID as randomUUID3 } from "node:crypto";
530
- import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
531
- import { z as z8 } from "zod";
532
-
533
527
  // packages/state/sqlite.ts
534
528
  import { chmodSync, mkdirSync } from "node:fs";
535
529
  import { dirname } from "node:path";
@@ -571,6 +565,317 @@ function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
571
565
  }
572
566
  }
573
567
 
568
+ // apps/client/files/publication-state.ts
569
+ var FilePublicationState = class {
570
+ constructor(db, scope = "member") {
571
+ this.db = db;
572
+ const prefix = scope === "project" ? "project_" : "";
573
+ this.tables = {
574
+ publications: prefix + "publications",
575
+ owner_files: prefix + "owner_files",
576
+ outbox: prefix + "outbox"
577
+ };
578
+ }
579
+ db;
580
+ tables;
581
+ discard(root) {
582
+ this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
583
+ }
584
+ publication(root) {
585
+ 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) };
588
+ }
589
+ initializePublication(root, revision, files, options = {}) {
590
+ transaction(this.db, () => {
591
+ if (options.discardPending) this.discard(root);
592
+ if (this.pending(root))
593
+ throw new Error("Cannot replace a baseline with a publication pending.");
594
+ 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);
597
+ this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=?`).run(root);
598
+ for (const file of files)
599
+ this.db.prepare(`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?)`).run(root, file.path, file.sha256, file.size);
600
+ });
601
+ }
602
+ ownerFiles(root) {
603
+ return new Map(
604
+ this.db.prepare(`SELECT path, hash, size FROM ${this.tables.owner_files} WHERE root=?`).all(root).map((row) => [String(row.path), { sha256: String(row.hash), size: Number(row.size) }])
605
+ );
606
+ }
607
+ pending(root) {
608
+ const row = this.db.prepare(
609
+ `SELECT request_json, attempts, retry_at, last_error FROM ${this.tables.outbox} WHERE root=?`
610
+ ).get(root);
611
+ return row ? {
612
+ batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
613
+ attempts: Number(row.attempts),
614
+ retryAt: Number(row.retry_at),
615
+ lastError: row.last_error === null ? null : String(row.last_error)
616
+ } : void 0;
617
+ }
618
+ enqueue(root, batch) {
619
+ const fixed = FileCommitSchema.parse(batch);
620
+ transaction(this.db, () => {
621
+ const publication = this.publication(root);
622
+ if (!publication.initialized || publication.revision !== fixed.parentRevision)
623
+ throw new Error("Publication baseline is not ready.");
624
+ if (this.pending(root)) throw new Error("A publication is already pending.");
625
+ this.db.prepare(`INSERT INTO ${this.tables.outbox}(root, request_json) VALUES (?, ?)`).run(root, JSON.stringify(fixed));
626
+ });
627
+ }
628
+ acknowledge(root, result2) {
629
+ transaction(this.db, () => {
630
+ const pending = this.pending(root);
631
+ if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
632
+ throw new Error("Unexpected publication acknowledgement.");
633
+ for (const change of pending.batch.changes) {
634
+ if (change.kind === "delete")
635
+ this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=? AND path=?`).run(root, change.path);
636
+ else
637
+ this.db.prepare(
638
+ `INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size`
639
+ ).run(root, change.path, change.sha256, change.size);
640
+ }
641
+ this.db.prepare(`UPDATE ${this.tables.publications} SET revision=? WHERE root=?`).run(result2.revision, root);
642
+ this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
643
+ });
644
+ }
645
+ postpone(root, operationId, reason, now, blocked) {
646
+ const pending = this.pending(root);
647
+ if (!pending || pending.batch.operationId !== operationId) return;
648
+ const retryAt = blocked ? Number.MAX_SAFE_INTEGER : now + Math.min(9e5, 6e4 * 2 ** Math.min(pending.attempts, 4));
649
+ this.db.prepare(
650
+ `UPDATE ${this.tables.outbox} SET attempts=attempts+1, retry_at=?, last_error=? WHERE root=?`
651
+ ).run(retryAt, reason, root);
652
+ }
653
+ retryPublication(root) {
654
+ this.db.prepare(`UPDATE ${this.tables.outbox} SET retry_at=0, last_error=NULL WHERE root=?`).run(root);
655
+ }
656
+ };
657
+
658
+ // apps/client/files/state.ts
659
+ var syncMigration = String.raw`
660
+ CREATE TABLE file_index (
661
+ root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT, dirty INTEGER NOT NULL DEFAULT 1,
662
+ event_pending INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 1,
663
+ observed_at REAL NOT NULL, hash TEXT, verified_at REAL NOT NULL DEFAULT 0,
664
+ PRIMARY KEY(root, path)
665
+ );
666
+ CREATE INDEX dirty_files ON file_index(root, dirty, observed_at);
667
+ CREATE INDEX audit_files ON file_index(root, dirty, verified_at);
668
+ CREATE TABLE received_files (root TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, owner, path));
669
+ CREATE TABLE received_progress (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(root, owner));
670
+ CREATE TABLE receive_jobs (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, changes_json TEXT NOT NULL, next_index INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root, owner));
671
+ CREATE TABLE sync_status (root TEXT PRIMARY KEY, status_json TEXT NOT NULL);
672
+ `;
673
+ var SyncState = class {
674
+ constructor(state, root, scope = "member") {
675
+ this.root = root;
676
+ this.db = state.db;
677
+ const prefix = scope === "project" ? "project_" : "";
678
+ this.tables = {
679
+ file_index: prefix + "file_index",
680
+ received_files: prefix + "received_files",
681
+ received_progress: prefix + "received_progress",
682
+ receive_jobs: prefix + "receive_jobs",
683
+ sync_status: prefix + "sync_status"
684
+ };
685
+ }
686
+ root;
687
+ db;
688
+ tables;
689
+ index(onlyDirty = false) {
690
+ return this.db.prepare(
691
+ `SELECT * FROM ${this.tables.file_index} WHERE root=?${onlyDirty ? " AND dirty=1" : ""}`
692
+ ).all(this.root).map((row) => ({
693
+ path: String(row.path),
694
+ signature: row.signature === null ? null : String(row.signature),
695
+ dirty: Boolean(row.dirty),
696
+ eventPending: Boolean(row.event_pending),
697
+ generation: Number(row.generation),
698
+ observedAt: Number(row.observed_at),
699
+ hash: row.hash === null ? null : String(row.hash),
700
+ verifiedAt: Number(row.verified_at),
701
+ seenLocal: Boolean(row.seen_local)
702
+ }));
703
+ }
704
+ audit(before, limit) {
705
+ return this.db.prepare(
706
+ `SELECT path FROM ${this.tables.file_index} WHERE root=? AND dirty=0 AND signature IS NOT NULL AND verified_at<=? ORDER BY verified_at LIMIT ?`
707
+ ).all(this.root, before, limit).map((row) => this.get(String(row.path)));
708
+ }
709
+ get(path) {
710
+ const row = this.db.prepare(`SELECT * FROM ${this.tables.file_index} WHERE root=? AND path=?`).get(this.root, path);
711
+ return row ? {
712
+ path,
713
+ signature: row.signature === null ? null : String(row.signature),
714
+ dirty: Boolean(row.dirty),
715
+ eventPending: Boolean(row.event_pending),
716
+ generation: Number(row.generation),
717
+ observedAt: Number(row.observed_at),
718
+ hash: row.hash === null ? null : String(row.hash),
719
+ verifiedAt: Number(row.verified_at),
720
+ seenLocal: Boolean(row.seen_local)
721
+ } : void 0;
722
+ }
723
+ restartWaiting(now) {
724
+ this.db.prepare(`UPDATE ${this.tables.file_index} SET observed_at=? WHERE root=? AND dirty=1`).run(now, this.root);
725
+ }
726
+ event(path, now) {
727
+ this.db.prepare(
728
+ `INSERT INTO ${this.tables.file_index}(root, path, observed_at, event_pending) VALUES (?, ?, ?, 1)
729
+ ON CONFLICT(root,path) DO UPDATE SET dirty=1, event_pending=1, generation=generation+1, observed_at=excluded.observed_at`
730
+ ).run(this.root, path, now);
731
+ this.db.prepare(
732
+ `UPDATE ${this.tables.file_index} SET dirty=1, event_pending=1, generation=generation+1, observed_at=? WHERE root=? AND substr(path,1,length(?))=?`
733
+ ).run(now, this.root, `${path}/`, `${path}/`);
734
+ }
735
+ observe(path, signature2, now) {
736
+ const prior = this.get(path);
737
+ if (!prior) {
738
+ if (signature2 !== null)
739
+ this.db.prepare(
740
+ `INSERT INTO ${this.tables.file_index}(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)`
741
+ ).run(this.root, path, signature2, now);
742
+ } else if (signature2 !== prior.signature) {
743
+ this.db.prepare(
744
+ `UPDATE ${this.tables.file_index} SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?`
745
+ ).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
746
+ } else if (prior.eventPending) {
747
+ this.db.prepare(`UPDATE ${this.tables.file_index} SET event_pending=0 WHERE root=? AND path=?`).run(this.root, path);
748
+ }
749
+ }
750
+ clean(path, generation, hash2, now) {
751
+ this.db.prepare(
752
+ `UPDATE ${this.tables.file_index} SET dirty=0, hash=?, verified_at=? WHERE root=? AND path=? AND generation=?`
753
+ ).run(hash2, now, this.root, path, generation);
754
+ }
755
+ verified(path, now) {
756
+ this.db.prepare(`UPDATE ${this.tables.file_index} SET verified_at=? WHERE root=? AND path=?`).run(now, this.root, path);
757
+ }
758
+ revision(owner) {
759
+ const row = this.db.prepare(`SELECT revision FROM ${this.tables.received_progress} WHERE root=? AND owner=?`).get(this.root, owner);
760
+ return row ? Number(row.revision) : void 0;
761
+ }
762
+ // The author's acknowledged publication is also its last known mirror baseline.
763
+ // Persist this without reapplying bytes: local edits may already be newer.
764
+ acknowledgeOwnFiles(owner, revision, files) {
765
+ if ((this.revision(owner) ?? -1) >= revision || this.job(owner)) return;
766
+ transaction(this.db, () => {
767
+ this.db.prepare(`DELETE FROM ${this.tables.received_files} WHERE root=? AND owner=?`).run(this.root, owner);
768
+ for (const file of files)
769
+ this.db.prepare(`INSERT INTO ${this.tables.received_files} VALUES (?,?,?,?,?)`).run(this.root, owner, file.path, file.sha256, file.size);
770
+ this.db.prepare(
771
+ `INSERT INTO ${this.tables.received_progress} VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision`
772
+ ).run(this.root, owner, revision);
773
+ });
774
+ }
775
+ received(owner, path) {
776
+ const row = this.db.prepare(
777
+ `SELECT hash,size FROM ${this.tables.received_files} WHERE root=? AND owner=? AND path=?`
778
+ ).get(this.root, owner, path);
779
+ return row ? { sha256: String(row.hash), size: Number(row.size) } : void 0;
780
+ }
781
+ job(owner) {
782
+ const row = this.db.prepare(`SELECT * FROM ${this.tables.receive_jobs} WHERE root=? AND owner=?`).get(this.root, owner);
783
+ return row ? {
784
+ revision: Number(row.revision),
785
+ changes: JSON.parse(String(row.changes_json)),
786
+ nextIndex: Number(row.next_index)
787
+ } : void 0;
788
+ }
789
+ queue(owner, revision, changes) {
790
+ this.db.prepare(
791
+ `INSERT INTO ${this.tables.receive_jobs}(root, owner, revision, changes_json) VALUES (?, ?, ?, ?)`
792
+ ).run(this.root, owner, revision, JSON.stringify(changes));
793
+ }
794
+ applied(owner, change, nextIndex) {
795
+ transaction(this.db, () => {
796
+ if (change.kind === "delete")
797
+ this.db.prepare(`DELETE FROM ${this.tables.received_files} WHERE root=? AND owner=? AND path=?`).run(this.root, owner, change.path);
798
+ else
799
+ this.db.prepare(
800
+ `INSERT INTO ${this.tables.received_files} VALUES (?,?,?,?,?) ON CONFLICT(root,owner,path) DO UPDATE SET hash=excluded.hash,size=excluded.size`
801
+ ).run(this.root, owner, change.path, change.sha256, change.size);
802
+ this.db.prepare(`UPDATE ${this.tables.receive_jobs} SET next_index=? WHERE root=? AND owner=?`).run(nextIndex, this.root, owner);
803
+ });
804
+ }
805
+ finish(owner) {
806
+ transaction(this.db, () => {
807
+ const job = this.job(owner);
808
+ if (!job || job.nextIndex !== job.changes.length)
809
+ throw new Error("Receive batch is not fully applied.");
810
+ this.db.prepare(
811
+ `INSERT INTO ${this.tables.received_progress} VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision`
812
+ ).run(this.root, owner, job.revision);
813
+ this.db.prepare(`DELETE FROM ${this.tables.receive_jobs} WHERE root=? AND owner=?`).run(this.root, owner);
814
+ });
815
+ }
816
+ saveStatus(status) {
817
+ this.db.prepare(
818
+ `INSERT INTO ${this.tables.sync_status} VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json`
819
+ ).run(this.root, JSON.stringify(status));
820
+ }
821
+ status() {
822
+ const row = this.db.prepare(`SELECT status_json FROM ${this.tables.sync_status} WHERE root=?`).get(this.root);
823
+ return row ? JSON.parse(String(row.status_json)) : null;
824
+ }
825
+ };
826
+
827
+ // apps/client/files/project-schema.ts
828
+ var projectFilesClientMigration = `
829
+ CREATE TABLE project_publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
830
+ CREATE TABLE project_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));
831
+ CREATE TABLE project_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);
832
+ ${syncMigration.replace(/\b(file_index|dirty_files|audit_files|received_files|received_progress|receive_jobs|sync_status)\b/g, "project_$1")}
833
+ ALTER TABLE project_file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
834
+ CREATE TABLE project_file_settings (root TEXT PRIMARY KEY REFERENCES connections(root), config_json TEXT NOT NULL);
835
+ `;
836
+
837
+ // apps/client/runtime/state.ts
838
+ var runtimeMigration = `
839
+ ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
840
+ UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
841
+ CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
842
+ CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
843
+ `;
844
+ var RuntimeState = class {
845
+ constructor(state) {
846
+ this.state = state;
847
+ }
848
+ state;
849
+ enabled(root) {
850
+ return Boolean(
851
+ this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
852
+ );
853
+ }
854
+ enable(root, enabled) {
855
+ if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
856
+ this.state.db.prepare(
857
+ "INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
858
+ ).run(root, Number(enabled));
859
+ }
860
+ roots() {
861
+ return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
862
+ }
863
+ status(root) {
864
+ const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
865
+ return row?.status_json ? JSON.parse(String(row.status_json)) : null;
866
+ }
867
+ save(root, status) {
868
+ this.state.db.prepare(
869
+ "INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
870
+ ).run(root, JSON.stringify(status));
871
+ }
872
+ };
873
+
874
+ // 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";
878
+
574
879
  // apps/client/workspace.ts
575
880
  import { execFile } from "node:child_process";
576
881
  import { promisify } from "node:util";
@@ -591,7 +896,7 @@ function signature(stat2) {
591
896
  }
592
897
  function ignored(path) {
593
898
  return path.split("/").some(
594
- (part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:-|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
899
+ (part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:[-.]|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
595
900
  );
596
901
  }
597
902
  async function safePath(root, path, createParents = false) {
@@ -626,7 +931,7 @@ async function metadata(root, path) {
626
931
  throw error;
627
932
  }
628
933
  }
629
- async function walk(root, prefix = "") {
934
+ async function walk(root, prefix = "", excludes = ignored) {
630
935
  const files = /* @__PURE__ */ new Map();
631
936
  const issues = [];
632
937
  const directory = prefix ? await safePath(root, prefix) : root;
@@ -644,7 +949,7 @@ async function walk(root, prefix = "") {
644
949
  throw new Error(`Directory changed while scanning: ${current}`);
645
950
  for (const entry of await readdir(full, { withFileTypes: true })) {
646
951
  const path = current ? `${current}/${entry.name}` : entry.name;
647
- if (ignored(path)) continue;
952
+ if (excludes(path, entry.isDirectory())) continue;
648
953
  try {
649
954
  FilePathSchema.parse(path);
650
955
  const stat2 = await lstat(await safePath(root, path), { bigint: true });
@@ -846,8 +1151,8 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
846
1151
  }
847
1152
  if (prior && !prior.complete && prior.input_json === JSON.stringify(input)) {
848
1153
  await verifyWorkspace(state, root);
849
- const ignore2 = await ignorePlan(root);
850
- if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
1154
+ const ignore3 = await ignorePlan(root);
1155
+ if (ignore3) await replaceFile(ignore3.parent, ".gitignore", ignore3.bytes, ignore3.signature);
851
1156
  return;
852
1157
  }
853
1158
  throw new Error(
@@ -856,7 +1161,7 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
856
1161
  }
857
1162
  if (state.binding(root) || prior)
858
1163
  throw new Error("Connected workspace is missing. Restore its directory before syncing.");
859
- const ignore = await ignorePlan(root);
1164
+ const ignore2 = await ignorePlan(root);
860
1165
  await mkdir2(root);
861
1166
  try {
862
1167
  const identity = await directoryIdentity(root);
@@ -867,7 +1172,7 @@ async function prepareWorkspace(state, root, input, reconnect = false) {
867
1172
  });
868
1173
  throw e;
869
1174
  }
870
- if (ignore) await replaceFile(ignore.parent, ".gitignore", ignore.bytes, ignore.signature);
1175
+ if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
871
1176
  }
872
1177
  function completePreparation(state, root) {
873
1178
  state.db.prepare("UPDATE client_preparations SET complete=1 WHERE root=?").run(root);
@@ -950,43 +1255,43 @@ var ClientLog = class {
950
1255
  import { setTimeout as setTimeout2 } from "node:timers/promises";
951
1256
 
952
1257
  // packages/protocol/devices.ts
953
- import { z as z7 } from "zod";
954
- var CredentialSchema = z7.string().regex(/^tmy_[a-f0-9]{64}$/);
955
- var EnrollmentSchema = z7.object({
956
- operationId: z7.uuid(),
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(),
957
1262
  projectId: NameSchema,
958
1263
  memberId: NameSchema,
959
1264
  deviceId: DeviceIdSchema,
960
1265
  credential: CredentialSchema
961
1266
  }).strict();
962
- var EffectivePolicySchema = z7.object({
963
- checkIntervalSeconds: z7.number().min(1).max(86400),
964
- fileSettleSeconds: z7.number().min(0).max(86400)
1267
+ var EffectivePolicySchema = z8.object({
1268
+ checkIntervalSeconds: z8.number().min(1).max(86400),
1269
+ fileSettleSeconds: z8.number().min(0).max(86400)
965
1270
  }).strict();
966
- var SyncSummarySchema = z7.object({
967
- checkedAt: z7.string(),
968
- published: z7.boolean(),
969
- appliedFiles: z7.number().int().nonnegative(),
970
- pendingFiles: z7.number().int().nonnegative(),
971
- sessionSources: z7.number().int().nonnegative(),
972
- errors: z7.array(z7.string().max(1e3)).max(20)
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)
973
1278
  }).strict();
974
- var DeviceStatusSchema = z7.object({
975
- clientVersion: z7.string().max(64),
1279
+ var DeviceStatusSchema = z8.object({
1280
+ clientVersion: z8.string().max(64),
976
1281
  receivedRevision: RevisionSchema.nullable(),
977
1282
  appliedRevision: RevisionSchema.nullable(),
978
1283
  effectivePolicy: EffectivePolicySchema,
979
- overrides: z7.array(z7.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
980
- configError: z7.string().max(2e3).nullable(),
981
- reportError: z7.string().max(2e3).nullable(),
1284
+ overrides: z8.array(z8.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
1285
+ configError: z8.string().max(2e3).nullable(),
1286
+ reportError: z8.string().max(2e3).nullable(),
982
1287
  lastSync: SyncSummarySchema.nullable()
983
1288
  }).strict();
984
- var WebDevicesSchema = z7.object({
985
- devices: z7.array(
986
- z7.object({
1289
+ var WebDevicesSchema = z8.object({
1290
+ devices: z8.array(
1291
+ z8.object({
987
1292
  memberId: NameSchema,
988
- bound: z7.boolean(),
989
- lastSeenAt: z7.string().nullable(),
1293
+ bound: z8.boolean(),
1294
+ lastSeenAt: z8.string().nullable(),
990
1295
  status: DeviceStatusSchema.nullable()
991
1296
  })
992
1297
  )
@@ -1297,157 +1602,15 @@ var SessionState = class {
1297
1602
  }
1298
1603
  };
1299
1604
 
1300
- // apps/client/files/state.ts
1301
- var syncMigration = String.raw`
1302
- CREATE TABLE file_index (
1303
- root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT, dirty INTEGER NOT NULL DEFAULT 1,
1304
- event_pending INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 1,
1305
- observed_at REAL NOT NULL, hash TEXT, verified_at REAL NOT NULL DEFAULT 0,
1306
- PRIMARY KEY(root, path)
1307
- );
1308
- CREATE INDEX dirty_files ON file_index(root, dirty, observed_at);
1309
- CREATE INDEX audit_files ON file_index(root, dirty, verified_at);
1310
- CREATE TABLE received_files (root TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, owner, path));
1311
- CREATE TABLE received_progress (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(root, owner));
1312
- CREATE TABLE receive_jobs (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, changes_json TEXT NOT NULL, next_index INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root, owner));
1313
- CREATE TABLE sync_status (root TEXT PRIMARY KEY, status_json TEXT NOT NULL);
1314
- `;
1315
- var SyncState = class {
1316
- constructor(state, root) {
1317
- this.root = root;
1318
- this.db = state.db;
1319
- }
1320
- root;
1321
- db;
1322
- index(onlyDirty = false) {
1323
- return this.db.prepare(`SELECT * FROM file_index WHERE root=?${onlyDirty ? " AND dirty=1" : ""}`).all(this.root).map((row) => ({
1324
- path: String(row.path),
1325
- signature: row.signature === null ? null : String(row.signature),
1326
- dirty: Boolean(row.dirty),
1327
- eventPending: Boolean(row.event_pending),
1328
- generation: Number(row.generation),
1329
- observedAt: Number(row.observed_at),
1330
- hash: row.hash === null ? null : String(row.hash),
1331
- verifiedAt: Number(row.verified_at),
1332
- seenLocal: Boolean(row.seen_local)
1333
- }));
1334
- }
1335
- audit(before, limit) {
1336
- return this.db.prepare(
1337
- "SELECT path FROM file_index WHERE root=? AND dirty=0 AND signature IS NOT NULL AND verified_at<=? ORDER BY verified_at LIMIT ?"
1338
- ).all(this.root, before, limit).map((row) => this.get(String(row.path)));
1339
- }
1340
- get(path) {
1341
- const row = this.db.prepare("SELECT * FROM file_index WHERE root=? AND path=?").get(this.root, path);
1342
- return row ? {
1343
- path,
1344
- signature: row.signature === null ? null : String(row.signature),
1345
- dirty: Boolean(row.dirty),
1346
- eventPending: Boolean(row.event_pending),
1347
- generation: Number(row.generation),
1348
- observedAt: Number(row.observed_at),
1349
- hash: row.hash === null ? null : String(row.hash),
1350
- verifiedAt: Number(row.verified_at),
1351
- seenLocal: Boolean(row.seen_local)
1352
- } : void 0;
1353
- }
1354
- restartWaiting(now) {
1355
- this.db.prepare("UPDATE file_index SET observed_at=? WHERE root=? AND dirty=1").run(now, this.root);
1356
- }
1357
- event(path, now) {
1358
- this.db.prepare(
1359
- `INSERT INTO file_index(root, path, observed_at, event_pending) VALUES (?, ?, ?, 1)
1360
- ON CONFLICT(root,path) DO UPDATE SET dirty=1, event_pending=1, generation=generation+1, observed_at=excluded.observed_at`
1361
- ).run(this.root, path, now);
1362
- this.db.prepare(
1363
- "UPDATE file_index SET dirty=1, event_pending=1, generation=generation+1, observed_at=? WHERE root=? AND substr(path,1,length(?))=?"
1364
- ).run(now, this.root, `${path}/`, `${path}/`);
1365
- }
1366
- observe(path, signature2, now) {
1367
- const prior = this.get(path);
1368
- if (!prior) {
1369
- if (signature2 !== null)
1370
- this.db.prepare(
1371
- "INSERT INTO file_index(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)"
1372
- ).run(this.root, path, signature2, now);
1373
- } else if (signature2 !== prior.signature) {
1374
- this.db.prepare(
1375
- "UPDATE file_index SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
1376
- ).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
1377
- } else if (prior.eventPending) {
1378
- this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
1379
- }
1380
- }
1381
- clean(path, generation, hash2, now) {
1382
- this.db.prepare(
1383
- "UPDATE file_index SET dirty=0, hash=?, verified_at=? WHERE root=? AND path=? AND generation=?"
1384
- ).run(hash2, now, this.root, path, generation);
1385
- }
1386
- verified(path, now) {
1387
- this.db.prepare("UPDATE file_index SET verified_at=? WHERE root=? AND path=?").run(now, this.root, path);
1388
- }
1389
- revision(owner) {
1390
- const row = this.db.prepare("SELECT revision FROM received_progress WHERE root=? AND owner=?").get(this.root, owner);
1391
- return row ? Number(row.revision) : void 0;
1392
- }
1393
- received(owner, path) {
1394
- const row = this.db.prepare("SELECT hash,size FROM received_files WHERE root=? AND owner=? AND path=?").get(this.root, owner, path);
1395
- return row ? { sha256: String(row.hash), size: Number(row.size) } : void 0;
1396
- }
1397
- job(owner) {
1398
- const row = this.db.prepare("SELECT * FROM receive_jobs WHERE root=? AND owner=?").get(this.root, owner);
1399
- return row ? {
1400
- revision: Number(row.revision),
1401
- changes: JSON.parse(String(row.changes_json)),
1402
- nextIndex: Number(row.next_index)
1403
- } : void 0;
1404
- }
1405
- queue(owner, revision, changes) {
1406
- this.db.prepare("INSERT INTO receive_jobs(root, owner, revision, changes_json) VALUES (?, ?, ?, ?)").run(this.root, owner, revision, JSON.stringify(changes));
1407
- }
1408
- applied(owner, change, nextIndex) {
1409
- transaction(this.db, () => {
1410
- if (change.kind === "delete")
1411
- this.db.prepare("DELETE FROM received_files WHERE root=? AND owner=? AND path=?").run(this.root, owner, change.path);
1412
- else
1413
- this.db.prepare(
1414
- "INSERT INTO received_files VALUES (?,?,?,?,?) ON CONFLICT(root,owner,path) DO UPDATE SET hash=excluded.hash,size=excluded.size"
1415
- ).run(this.root, owner, change.path, change.sha256, change.size);
1416
- this.db.prepare("UPDATE receive_jobs SET next_index=? WHERE root=? AND owner=?").run(nextIndex, this.root, owner);
1417
- });
1418
- }
1419
- finish(owner) {
1420
- transaction(this.db, () => {
1421
- const job = this.job(owner);
1422
- if (!job || job.nextIndex !== job.changes.length)
1423
- throw new Error("Receive batch is not fully applied.");
1424
- this.db.prepare(
1425
- "INSERT INTO received_progress VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision"
1426
- ).run(this.root, owner, job.revision);
1427
- this.db.prepare("DELETE FROM receive_jobs WHERE root=? AND owner=?").run(this.root, owner);
1428
- });
1429
- }
1430
- saveStatus(status) {
1431
- this.db.prepare(
1432
- "INSERT INTO sync_status VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
1433
- ).run(this.root, JSON.stringify(status));
1434
- }
1435
- status() {
1436
- const row = this.db.prepare("SELECT status_json FROM sync_status WHERE root=?").get(this.root);
1437
- return row ? JSON.parse(String(row.status_json)) : null;
1438
- }
1439
- };
1440
-
1441
1605
  // apps/client/state.ts
1442
1606
  var LocalBindingSchema = BindingSchema.extend({
1443
- root: z8.string(),
1444
- serverUrl: z8.string(),
1445
- token: z8.string()
1607
+ root: z9.string(),
1608
+ serverUrl: z9.string(),
1609
+ token: z9.string()
1446
1610
  });
1447
- var ClientState = class {
1448
- db;
1611
+ var ClientState = class extends FilePublicationState {
1449
1612
  constructor(directory) {
1450
- this.db = openDatabase(join4(directory, "client.sqlite3"), [
1613
+ const db = openDatabase(join4(directory, "client.sqlite3"), [
1451
1614
  String.raw`
1452
1615
  CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
1453
1616
  `,
@@ -1460,8 +1623,10 @@ var ClientState = class {
1460
1623
  sessionClientMigration,
1461
1624
  projectClientMigration,
1462
1625
  controlClientMigration,
1463
- runtimeMigration
1626
+ runtimeMigration,
1627
+ projectFilesClientMigration
1464
1628
  ]);
1629
+ super(db);
1465
1630
  }
1466
1631
  close() {
1467
1632
  this.db.close();
@@ -1503,73 +1668,6 @@ var ClientState = class {
1503
1668
  this.db.prepare("UPDATE connections SET binding_json=? WHERE root=?").run(JSON.stringify(local), root);
1504
1669
  return local;
1505
1670
  }
1506
- publication(root) {
1507
- this.db.prepare("INSERT OR IGNORE INTO publications(root) VALUES (?)").run(root);
1508
- const row = this.db.prepare("SELECT revision, initialized FROM publications WHERE root=?").get(root);
1509
- return { revision: Number(row.revision), initialized: Boolean(row.initialized) };
1510
- }
1511
- initializePublication(root, revision, files) {
1512
- transaction(this.db, () => {
1513
- if (this.pending(root))
1514
- throw new Error("Cannot replace a baseline with a publication pending.");
1515
- this.db.prepare(
1516
- "INSERT INTO publications VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1"
1517
- ).run(root, revision);
1518
- this.db.prepare("DELETE FROM owner_files WHERE root=?").run(root);
1519
- for (const file of files)
1520
- this.db.prepare("INSERT INTO owner_files VALUES (?, ?, ?, ?)").run(root, file.path, file.sha256, file.size);
1521
- });
1522
- }
1523
- ownerFiles(root) {
1524
- return new Map(
1525
- this.db.prepare("SELECT path, hash, size FROM owner_files WHERE root=?").all(root).map((row) => [String(row.path), { sha256: String(row.hash), size: Number(row.size) }])
1526
- );
1527
- }
1528
- pending(root) {
1529
- const row = this.db.prepare("SELECT request_json, attempts, retry_at, last_error FROM outbox WHERE root=?").get(root);
1530
- return row ? {
1531
- batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
1532
- attempts: Number(row.attempts),
1533
- retryAt: Number(row.retry_at),
1534
- lastError: row.last_error === null ? null : String(row.last_error)
1535
- } : void 0;
1536
- }
1537
- enqueue(root, batch) {
1538
- const fixed = FileCommitSchema.parse(batch);
1539
- transaction(this.db, () => {
1540
- const publication = this.publication(root);
1541
- if (!publication.initialized || publication.revision !== fixed.parentRevision)
1542
- throw new Error("Publication baseline is not ready.");
1543
- if (this.pending(root)) throw new Error("A publication is already pending.");
1544
- this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
1545
- });
1546
- }
1547
- acknowledge(root, result2) {
1548
- transaction(this.db, () => {
1549
- const pending = this.pending(root);
1550
- if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
1551
- throw new Error("Unexpected publication acknowledgement.");
1552
- for (const change of pending.batch.changes) {
1553
- if (change.kind === "delete")
1554
- this.db.prepare("DELETE FROM owner_files WHERE root=? AND path=?").run(root, change.path);
1555
- else
1556
- this.db.prepare(
1557
- "INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
1558
- ).run(root, change.path, change.sha256, change.size);
1559
- }
1560
- this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result2.revision, root);
1561
- this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
1562
- });
1563
- }
1564
- postpone(root, operationId, reason, now, blocked) {
1565
- const pending = this.pending(root);
1566
- if (!pending || pending.batch.operationId !== operationId) return;
1567
- const retryAt = blocked ? Number.MAX_SAFE_INTEGER : now + Math.min(9e5, 6e4 * 2 ** Math.min(pending.attempts, 4));
1568
- this.db.prepare("UPDATE outbox SET attempts=attempts+1, retry_at=?, last_error=? WHERE root=?").run(retryAt, reason, root);
1569
- }
1570
- retryPublication(root) {
1571
- this.db.prepare("UPDATE outbox SET retry_at=0, last_error=NULL WHERE root=?").run(root);
1572
- }
1573
1671
  };
1574
1672
 
1575
1673
  // apps/client/state-location.ts
@@ -1593,7 +1691,7 @@ async function stateLocation(root, directory) {
1593
1691
  import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
1594
1692
  import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
1595
1693
  import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
1596
- import { z as z9 } from "zod";
1694
+ import { z as z10 } from "zod";
1597
1695
 
1598
1696
  // apps/client/sessions/normalize.ts
1599
1697
  function object(value) {
@@ -1711,10 +1809,10 @@ function normalizeJsonl(agent, text) {
1711
1809
  }
1712
1810
 
1713
1811
  // apps/client/sessions/hooks.ts
1714
- var HintSchema = z9.object({
1812
+ var HintSchema = z10.object({
1715
1813
  agent: AgentSchema,
1716
- path: z9.string().min(1).max(4096).refine(isAbsolute3),
1717
- projectDir: z9.string().min(1).max(4096).refine(isAbsolute3)
1814
+ path: z10.string().min(1).max(4096).refine(isAbsolute3),
1815
+ projectDir: z10.string().min(1).max(4096).refine(isAbsolute3)
1718
1816
  });
1719
1817
  function inbox(directory, root) {
1720
1818
  return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
@@ -1927,7 +2025,7 @@ import { setTimeout as setTimeout4 } from "node:timers/promises";
1927
2025
 
1928
2026
  // apps/client/client-command.ts
1929
2027
  import { parseArgs as parseArgs2 } from "node:util";
1930
- import { join as join10, resolve as resolve5 } from "node:path";
2028
+ import { join as join11, resolve as resolve5 } from "node:path";
1931
2029
  import { homedir as homedir3 } from "node:os";
1932
2030
  import { createInterface as createInterface2 } from "node:readline/promises";
1933
2031
 
@@ -1956,6 +2054,64 @@ function acquireRootLock(root, stateDirectory) {
1956
2054
  }
1957
2055
  }
1958
2056
 
2057
+ // 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";
2062
+ import ignore from "ignore";
2063
+ 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: [] })
2066
+ }).strict();
2067
+ async function ensureLocalConfig(root) {
2068
+ try {
2069
+ await writeFile(
2070
+ join8(root, LOCAL_CONFIG_NAME),
2071
+ JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
2072
+ { flag: "wx", mode: 384 }
2073
+ );
2074
+ } catch (error) {
2075
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
2076
+ }
2077
+ }
2078
+ async function loadLocalRules(root) {
2079
+ let rules = [];
2080
+ try {
2081
+ const file = await open3(
2082
+ join8(root, LOCAL_CONFIG_NAME),
2083
+ constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK
2084
+ );
2085
+ try {
2086
+ const before = await file.stat();
2087
+ if (!before.isFile() || before.size > 65536)
2088
+ throw new Error("Expected a regular configuration file no larger than 64 KiB.");
2089
+ const buffer = Buffer.alloc(65537);
2090
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
2091
+ if (bytesRead > 65536 || bytesRead !== before.size)
2092
+ throw new Error("Configuration exceeds the read limit or changed while reading.");
2093
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead));
2094
+ const after = await file.stat();
2095
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
2096
+ throw new Error("Configuration is being saved; retry on the next check.");
2097
+ rules = LocalConfigSchema.parse(JSON.parse(text)).files.ignore;
2098
+ } finally {
2099
+ await file.close();
2100
+ }
2101
+ } catch (error) {
2102
+ if (!missing(error))
2103
+ throw new Error(
2104
+ `${LOCAL_CONFIG_NAME}: invalid or unreadable configuration; uploads paused.`,
2105
+ { cause: error }
2106
+ );
2107
+ }
2108
+ const matcher = ignore().add(rules);
2109
+ return {
2110
+ fingerprint: JSON.stringify(rules),
2111
+ excludes: (path, directory = false) => ignored(path) || matcher.ignores(path + (directory && !path.endsWith("/") ? "/" : ""))
2112
+ };
2113
+ }
2114
+
1959
2115
  // apps/client/connection.ts
1960
2116
  async function connectProject(state, root, directory, options) {
1961
2117
  const lock = acquireRootLock(root, directory);
@@ -2030,6 +2186,7 @@ async function connectProject(state, root, directory, options) {
2030
2186
  const own = await metadata(root, binding.memberId);
2031
2187
  if (own && !own.directory) throw new Error("Member path is not a directory.");
2032
2188
  if (!own) await mkdir5(await safePath(root, binding.memberId));
2189
+ await ensureLocalConfig(root);
2033
2190
  const { token: _, ...visible } = binding;
2034
2191
  return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
2035
2192
  } finally {
@@ -2040,21 +2197,21 @@ async function connectProject(state, root, directory, options) {
2040
2197
  // apps/client/runtime/ipc.ts
2041
2198
  import { createConnection } from "node:net";
2042
2199
  import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
2043
- import { join as join8 } from "node:path";
2044
- import { z as z10 } from "zod";
2045
- var RequestSchema = z10.object({
2046
- method: z10.enum(["ping", "wake", "status", "once"]),
2047
- root: z10.string().max(4096).optional()
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()
2048
2205
  }).strict();
2049
2206
  function socketPath(directory) {
2050
- return join8(
2207
+ return join9(
2051
2208
  "/tmp",
2052
2209
  `teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
2053
2210
  "control.sock"
2054
2211
  );
2055
2212
  }
2056
2213
  async function prepareSocketDirectory(directory) {
2057
- const path = join8(socketPath(directory), "..");
2214
+ const path = join9(socketPath(directory), "..");
2058
2215
  await mkdir6(path, { recursive: true, mode: 448 });
2059
2216
  const stat2 = await lstat3(path);
2060
2217
  if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
@@ -2117,8 +2274,8 @@ async function runningDaemon(directory) {
2117
2274
  import { execFile as execFile2 } from "node:child_process";
2118
2275
  import { promisify as promisify2 } from "node:util";
2119
2276
  import { homedir as homedir2 } from "node:os";
2120
- import { dirname as dirname5, join as join9, resolve as resolve4 } from "node:path";
2121
- import { mkdir as mkdir7, readFile as readFile2, writeFile, rename as rename3 } from "node:fs/promises";
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";
2122
2279
  import { setTimeout as setTimeout3 } from "node:timers/promises";
2123
2280
  var execute = promisify2(execFile2);
2124
2281
  var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
@@ -2135,7 +2292,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
2135
2292
  if (platform === "darwin")
2136
2293
  return {
2137
2294
  id,
2138
- path: join9(userHome, "Library", "LaunchAgents", id + ".plist"),
2295
+ path: join10(userHome, "Library", "LaunchAgents", id + ".plist"),
2139
2296
  content: `<?xml version="1.0" encoding="UTF-8"?>
2140
2297
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2141
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>
@@ -2144,7 +2301,7 @@ function serviceDefinition(directory, executable, entry, platform = process.plat
2144
2301
  if (platform === "linux")
2145
2302
  return {
2146
2303
  id: id + ".service",
2147
- path: join9(userHome, ".config", "systemd", "user", id + ".service"),
2304
+ path: join10(userHome, ".config", "systemd", "user", id + ".service"),
2148
2305
  content: `[Unit]
2149
2306
  Description=Teammy background synchronization
2150
2307
  [Service]
@@ -2203,7 +2360,7 @@ async function ensureDaemon(directory, version) {
2203
2360
  await mkdir7(dirname5(definition.path), { recursive: true });
2204
2361
  if (old !== definition.content) {
2205
2362
  const temp = definition.path + `.${process.pid}.tmp`;
2206
- await writeFile(temp, definition.content, { mode: 384 });
2363
+ await writeFile2(temp, definition.content, { mode: 384 });
2207
2364
  await rename3(temp, definition.path);
2208
2365
  }
2209
2366
  if (process.platform === "darwin") {
@@ -2263,7 +2420,7 @@ function json(value) {
2263
2420
  // apps/client/client-command.ts
2264
2421
  var locationOptions = {
2265
2422
  root: { type: "string", default: resolve5("workspaces") },
2266
- "state-dir": { type: "string", default: join10(homedir3(), ".teammy", "client") },
2423
+ "state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") },
2267
2424
  json: { type: "boolean" },
2268
2425
  help: { type: "boolean", short: "h" }
2269
2426
  };
@@ -2385,13 +2542,13 @@ async function runClientCommand(command, args, version) {
2385
2542
  import { randomUUID as randomUUID6 } from "node:crypto";
2386
2543
  import { watch } from "node:fs";
2387
2544
  import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
2388
- import { join as join12, sep as sep4 } from "node:path";
2545
+ import { join as join13, sep as sep4 } from "node:path";
2389
2546
  import { performance } from "node:perf_hooks";
2390
2547
 
2391
2548
  // packages/storage/local-blob-store.ts
2392
2549
  import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
2393
- import { link, mkdir as mkdir8, open as open3, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
2394
- import { dirname as dirname6, join as join11, resolve as resolve6 } from "node:path";
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";
2395
2552
  function hash(content) {
2396
2553
  return createHash8("sha256").update(content).digest("hex");
2397
2554
  }
@@ -2417,7 +2574,7 @@ var LocalBlobStore = class {
2417
2574
  const target = this.pathFor(sha256);
2418
2575
  await mkdir8(dirname6(target), { recursive: true, mode: 448 });
2419
2576
  const temporary = `${target}.${randomUUID5()}.tmp`;
2420
- const file = await open3(temporary, "wx", 384);
2577
+ const file = await open4(temporary, "wx", 384);
2421
2578
  try {
2422
2579
  try {
2423
2580
  await file.writeFile(bytes);
@@ -2434,7 +2591,7 @@ var LocalBlobStore = class {
2434
2591
  if (process.platform !== "win32") {
2435
2592
  let directory = dirname6(target);
2436
2593
  for (; ; ) {
2437
- const handle = await open3(directory, "r");
2594
+ const handle = await open4(directory, "r");
2438
2595
  try {
2439
2596
  await handle.sync();
2440
2597
  } finally {
@@ -2463,7 +2620,7 @@ var LocalBlobStore = class {
2463
2620
  }
2464
2621
  pathFor(sha256) {
2465
2622
  if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
2466
- return join11(this.root, "sha256", sha256.slice(0, 2), sha256);
2623
+ return join12(this.root, "sha256", sha256.slice(0, 2), sha256);
2467
2624
  }
2468
2625
  };
2469
2626
 
@@ -2514,7 +2671,7 @@ var PublicationQueue = class {
2514
2671
  };
2515
2672
 
2516
2673
  // apps/client/files/synchronizer.ts
2517
- var FileSynchronizer = class {
2674
+ var FileSynchronizer = class _FileSynchronizer {
2518
2675
  constructor(state, root, stateDirectory, options = {}) {
2519
2676
  this.state = state;
2520
2677
  this.root = root;
@@ -2523,10 +2680,18 @@ var FileSynchronizer = class {
2523
2680
  if (!binding) throw new Error("Connect this root before synchronizing.");
2524
2681
  this.binding = binding;
2525
2682
  this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
2526
- this.blobs = new LocalBlobStore(join12(stateDirectory, "blobs"));
2527
- this.queue = new PublicationQueue(state, this.blobs, this.client);
2528
- this.index = new SyncState(state, root);
2529
- this.ownRoot = join12(root, binding.memberId);
2683
+ this.blobs = new LocalBlobStore(join13(stateDirectory, "blobs"));
2684
+ this.projectScope = options.scope === "project";
2685
+ this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
2686
+ this.publication = new FilePublicationState(state.db, options.scope);
2687
+ this.queue = new PublicationQueue(this.publication, this.blobs, {
2688
+ missingBlobs: (hashes) => this.client.missingBlobs(hashes),
2689
+ uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
2690
+ commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, this.sharedConfig.revision) : this.client.commitFiles(batch)
2691
+ });
2692
+ this.index = new SyncState(state, root, options.scope);
2693
+ this.ownRoot = this.projectScope ? root : join13(root, binding.memberId);
2694
+ this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
2530
2695
  const started = performance.now();
2531
2696
  const epoch = Date.now();
2532
2697
  this.clock = options.clock ?? (() => epoch + performance.now() - started);
@@ -2537,6 +2702,13 @@ var FileSynchronizer = class {
2537
2702
  root;
2538
2703
  options;
2539
2704
  index;
2705
+ publication;
2706
+ common;
2707
+ projectScope;
2708
+ owner;
2709
+ sharedConfig;
2710
+ rulesFingerprint;
2711
+ excludes = ignored;
2540
2712
  binding;
2541
2713
  client;
2542
2714
  blobs;
@@ -2561,9 +2733,11 @@ var FileSynchronizer = class {
2561
2733
  if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
2562
2734
  throw new Error("Invalid stable wait interval.");
2563
2735
  this.settleMs = value;
2736
+ this.common?.setSettleMs(value);
2564
2737
  }
2565
2738
  close() {
2566
2739
  this.stopped = true;
2740
+ this.common?.close();
2567
2741
  this.abort.abort();
2568
2742
  this.watcher?.close();
2569
2743
  this.watcher = void 0;
@@ -2581,8 +2755,56 @@ var FileSynchronizer = class {
2581
2755
  };
2582
2756
  try {
2583
2757
  try {
2758
+ if (this.projectScope) {
2759
+ try {
2760
+ this.sharedConfig = await this.client.sharedFilesConfig();
2761
+ } catch (error) {
2762
+ if (error instanceof HubRequestError && error.status === 404) return report;
2763
+ throw error;
2764
+ }
2765
+ const fingerprint = JSON.stringify(this.sharedConfig);
2766
+ const cached = this.state.db.prepare("SELECT config_json FROM project_file_settings WHERE root=?").get(this.root);
2767
+ const previous = cached ? JSON.parse(String(cached.config_json)) : void 0;
2768
+ const wasMaintainer = previous?.maintainer === this.binding.memberId;
2769
+ const isMaintainer = this.sharedConfig.maintainer === this.binding.memberId;
2770
+ const publication = this.publication.publication(this.root);
2771
+ if (wasMaintainer && publication.initialized && publication.revision > (this.index.revision(PROJECT_FILE_OWNER) ?? -1))
2772
+ this.index.acknowledgeOwnFiles(
2773
+ PROJECT_FILE_OWNER,
2774
+ publication.revision,
2775
+ [...this.publication.ownerFiles(this.root)].map(([path, file]) => ({
2776
+ path,
2777
+ ...file,
2778
+ revision: publication.revision
2779
+ }))
2780
+ );
2781
+ if (!isMaintainer) this.publication.discard(this.root);
2782
+ if (!wasMaintainer || !isMaintainer || previous?.revision !== this.sharedConfig.revision) {
2783
+ report.appliedFiles += await this.receive(PROJECT_FILE_OWNER);
2784
+ if (this.index.job(PROJECT_FILE_OWNER)) return report;
2785
+ }
2786
+ if (cached?.config_json !== fingerprint) {
2787
+ if (isMaintainer) await this.resetPublication();
2788
+ this.state.db.prepare(
2789
+ "INSERT INTO project_file_settings VALUES (?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json"
2790
+ ).run(this.root, fingerprint);
2791
+ this.lastScan = -Infinity;
2792
+ }
2793
+ if (!isMaintainer) return report;
2794
+ this.excludes = (path) => ignored(path) || !sharedPathAllowed(path, this.sharedConfig.paths);
2795
+ } else {
2796
+ const rules = await loadLocalRules(this.root);
2797
+ this.excludes = rules.excludes;
2798
+ if (this.rulesFingerprint !== rules.fingerprint) {
2799
+ this.rulesFingerprint = rules.fingerprint;
2800
+ this.lastScan = -Infinity;
2801
+ }
2802
+ }
2803
+ const pending = this.publication.pending(this.root);
2804
+ if (pending?.batch.changes.some((change) => this.excludes(change.path)))
2805
+ await this.resetPublication();
2584
2806
  await this.initializeAuthor();
2585
- const ownStat = await lstat4(await safePath(this.root, this.binding.memberId));
2807
+ const ownStat = await lstat4(this.ownRoot);
2586
2808
  if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
2587
2809
  throw new Error("Author directory is unavailable or is a symlink.");
2588
2810
  this.listen();
@@ -2593,14 +2815,14 @@ var FileSynchronizer = class {
2593
2815
  const blocked = await this.refreshDirty(report.issues);
2594
2816
  await this.audit(report.issues);
2595
2817
  report.published = await this.queue.flush(this.root, this.clock());
2596
- if (!this.state.pending(this.root) && !this.stopped) {
2818
+ if (!this.publication.pending(this.root) && !this.stopped) {
2597
2819
  await this.stage(blocked, report.issues, settleMs);
2598
2820
  report.published = await this.queue.flush(this.root, this.clock()) || report.published;
2599
2821
  }
2600
2822
  } catch (error) {
2601
2823
  report.issues.push(`publish: ${message(error)}`);
2602
2824
  }
2603
- if (!this.stopped) {
2825
+ if (!this.stopped && !this.projectScope) {
2604
2826
  try {
2605
2827
  for (const member of (await this.client.members()).members) {
2606
2828
  if (member === this.binding.memberId || this.stopped) continue;
@@ -2614,8 +2836,16 @@ var FileSynchronizer = class {
2614
2836
  report.issues.push(`receive: ${message(error)}`);
2615
2837
  }
2616
2838
  }
2617
- report.waitingFiles = this.index.index(true).length;
2618
- report.pendingPublication = Boolean(this.state.pending(this.root));
2839
+ if (this.common && !this.stopped) {
2840
+ const common = await this.common.cycle();
2841
+ report.published ||= common.published;
2842
+ report.appliedFiles += common.appliedFiles;
2843
+ report.waitingFiles += common.waitingFiles;
2844
+ report.pendingPublication ||= common.pendingPublication;
2845
+ report.issues.push(...common.issues.map((issue) => `project files: ${issue}`));
2846
+ }
2847
+ report.waitingFiles += this.index.index(true).filter((row) => !this.excludes(row.path)).length;
2848
+ report.pendingPublication ||= Boolean(this.publication.pending(this.root));
2619
2849
  this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
2620
2850
  return report;
2621
2851
  } finally {
@@ -2638,9 +2868,17 @@ var FileSynchronizer = class {
2638
2868
  after = page.nextCursor;
2639
2869
  }
2640
2870
  }
2871
+ async resetPublication() {
2872
+ const manifest = await this.manifest(this.owner);
2873
+ for (const row of this.index.index()) this.index.event(row.path, this.clock());
2874
+ this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
2875
+ discardPending: true
2876
+ });
2877
+ this.lastScan = -Infinity;
2878
+ }
2641
2879
  async initializeAuthor() {
2642
- if (this.state.publication(this.root).initialized) return;
2643
- const manifest = await this.manifest(this.binding.memberId);
2880
+ if (this.publication.publication(this.root).initialized) return;
2881
+ const manifest = await this.manifest(this.owner);
2644
2882
  if (manifest.revision > 0 && this.options.adoptBaseline) {
2645
2883
  for (const file of manifest.files) {
2646
2884
  const local = await readStable(this.ownRoot, file.path);
@@ -2650,11 +2888,11 @@ var FileSynchronizer = class {
2650
2888
  );
2651
2889
  }
2652
2890
  }
2653
- const ownerPath = await safePath(this.root, this.binding.memberId);
2891
+ const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
2654
2892
  await mkdir9(ownerPath).catch((error) => {
2655
2893
  if (error.code !== "EEXIST") throw error;
2656
2894
  });
2657
- this.state.initializePublication(this.root, manifest.revision, manifest.files);
2895
+ this.publication.initializePublication(this.root, manifest.revision, manifest.files);
2658
2896
  }
2659
2897
  listen() {
2660
2898
  if (this.options.watch === false || this.watcher || this.stopped) return;
@@ -2665,7 +2903,7 @@ var FileSynchronizer = class {
2665
2903
  return;
2666
2904
  }
2667
2905
  const path = filename.toString().split(sep4).join("/");
2668
- if (!ignored(path)) this.index.event(path, this.clock());
2906
+ if (!this.excludes(path)) this.index.event(path, this.clock());
2669
2907
  });
2670
2908
  this.watcher.on("error", (error) => {
2671
2909
  this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
@@ -2678,12 +2916,12 @@ var FileSynchronizer = class {
2678
2916
  }
2679
2917
  }
2680
2918
  async reconcile(prefix, issues) {
2681
- const scan = await walk(this.ownRoot, prefix);
2919
+ const scan = await walk(this.ownRoot, prefix, this.excludes);
2682
2920
  const now = this.clock();
2683
2921
  for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
2684
2922
  if (scan.complete) {
2685
2923
  for (const prior of this.index.index()) {
2686
- if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
2924
+ if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !this.excludes(prior.path) && !scan.files.has(prior.path))
2687
2925
  this.index.observe(prior.path, null, now);
2688
2926
  }
2689
2927
  }
@@ -2692,6 +2930,7 @@ var FileSynchronizer = class {
2692
2930
  async refreshDirty(issues) {
2693
2931
  const blocked = /* @__PURE__ */ new Set();
2694
2932
  for (const prior of this.index.index(true)) {
2933
+ if (this.excludes(prior.path)) continue;
2695
2934
  if (this.stopped) break;
2696
2935
  try {
2697
2936
  const current = await metadata(this.ownRoot, prior.path);
@@ -2712,6 +2951,7 @@ var FileSynchronizer = class {
2712
2951
  this.clock() - (this.options.auditAgeMs ?? 864e5),
2713
2952
  this.options.auditBudget ?? 20
2714
2953
  )) {
2954
+ if (this.excludes(prior.path)) continue;
2715
2955
  if (this.stopped || bytes > 20 * 1024 * 1024) break;
2716
2956
  try {
2717
2957
  const local = await readStable(this.ownRoot, prior.path);
@@ -2727,10 +2967,10 @@ var FileSynchronizer = class {
2727
2967
  }
2728
2968
  async stage(blocked, issues, settleMs) {
2729
2969
  const ready = this.index.index(true).filter(
2730
- (row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
2970
+ (row) => !this.excludes(row.path) && !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
2731
2971
  );
2732
2972
  if (!ready.length) return;
2733
- const baseline = this.state.ownerFiles(this.root);
2973
+ const baseline = this.publication.ownerFiles(this.root);
2734
2974
  const planned = new Map(baseline);
2735
2975
  const changes = [];
2736
2976
  const snapshots = [];
@@ -2788,9 +3028,9 @@ var FileSynchronizer = class {
2788
3028
  const rootStat = await lstat4(this.ownRoot);
2789
3029
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
2790
3030
  throw new Error("Author directory disappeared before publication.");
2791
- this.state.enqueue(this.root, {
3031
+ this.publication.enqueue(this.root, {
2792
3032
  operationId: randomUUID6(),
2793
- parentRevision: this.state.publication(this.root).revision,
3033
+ parentRevision: this.publication.publication(this.root).revision,
2794
3034
  changes
2795
3035
  });
2796
3036
  for (const snapshot of snapshots)
@@ -2834,7 +3074,7 @@ var FileSynchronizer = class {
2834
3074
  }
2835
3075
  }
2836
3076
  const job = this.index.job(owner);
2837
- const ownerRoot = await safePath(this.root, owner);
3077
+ const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
2838
3078
  await mkdir9(ownerRoot).catch((error) => {
2839
3079
  if (error.code !== "EEXIST") throw error;
2840
3080
  });
@@ -2891,7 +3131,7 @@ function message(error) {
2891
3131
 
2892
3132
  // apps/client/sessions/synchronizer.ts
2893
3133
  import { randomUUID as randomUUID7 } from "node:crypto";
2894
- import { join as join14 } from "node:path";
3134
+ import { join as join15 } from "node:path";
2895
3135
 
2896
3136
  // packages/protocol/session-key.ts
2897
3137
  import { createHash as createHash9 } from "node:crypto";
@@ -2900,7 +3140,7 @@ function sessionKey(agent, nativeId) {
2900
3140
  }
2901
3141
 
2902
3142
  // apps/client/sessions/source.ts
2903
- import { open as open4, stat, realpath as realpath5 } from "node:fs/promises";
3143
+ import { open as open5, stat, realpath as realpath5 } from "node:fs/promises";
2904
3144
  import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
2905
3145
  import * as zlib from "node:zlib";
2906
3146
 
@@ -3058,7 +3298,7 @@ async function readSource(agent, path, projectDir) {
3058
3298
  let result2;
3059
3299
  if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
3060
3300
  else {
3061
- const handle = await open4(path, "r");
3301
+ const handle = await open5(path, "r");
3062
3302
  let bytes;
3063
3303
  try {
3064
3304
  const size = (await handle.stat()).size;
@@ -3097,14 +3337,14 @@ async function readSource(agent, path, projectDir) {
3097
3337
 
3098
3338
  // apps/client/sessions/discovery.ts
3099
3339
  import { opendir as opendir2 } from "node:fs/promises";
3100
- import { join as join13 } from "node:path";
3340
+ import { join as join14 } from "node:path";
3101
3341
  async function* candidates(directory, agent) {
3102
3342
  const queue = [directory];
3103
3343
  while (queue.length) {
3104
3344
  const current = queue.shift();
3105
3345
  const dir = await opendir2(current);
3106
3346
  for await (const entry of dir) {
3107
- const path = join13(current, entry.name);
3347
+ const path = join14(current, entry.name);
3108
3348
  if (entry.isDirectory()) queue.push(path);
3109
3349
  if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
3110
3350
  yield path;
@@ -3192,7 +3432,7 @@ var SessionSynchronizer = class {
3192
3432
  const binding = state.binding(root);
3193
3433
  if (!binding) throw new Error("Connect this root before collecting sessions.");
3194
3434
  this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
3195
- this.blobs = new LocalBlobStore(join14(directory, "blobs"));
3435
+ this.blobs = new LocalBlobStore(join15(directory, "blobs"));
3196
3436
  this.index = new SessionState(state, root);
3197
3437
  this.discovery = new SessionDiscovery(this.index);
3198
3438
  }
@@ -3348,6 +3588,7 @@ var SyncJob = class {
3348
3588
  this.sessions = new SessionSynchronizer(state, root, directory, this.abort.signal);
3349
3589
  if (options.retry) {
3350
3590
  state.retryPublication(root);
3591
+ new FilePublicationState(state.db, "project").retryPublication(root);
3351
3592
  this.sessions.index.retry();
3352
3593
  }
3353
3594
  const overrides = {};
@@ -3449,12 +3690,15 @@ var SyncJob = class {
3449
3690
  (e) => Boolean(e)
3450
3691
  ) : []
3451
3692
  ].map(redact);
3452
- const pendingDownloads = Number(
3453
- this.state.db.prepare(
3454
- "SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM receive_jobs WHERE root=?"
3455
- ).get(this.root).n
3693
+ const pendingDownloads = ["receive_jobs", "project_receive_jobs"].reduce(
3694
+ (total, table) => total + Number(
3695
+ this.state.db.prepare(
3696
+ `SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM ${table} WHERE root=?`
3697
+ ).get(this.root).n
3698
+ ),
3699
+ 0
3456
3700
  );
3457
- const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0);
3701
+ const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0) + (new FilePublicationState(this.state.db, "project").pending(this.root)?.batch.changes.length ?? 0);
3458
3702
  const now = Date.now(), checkedAt = new Date(now).toISOString();
3459
3703
  this.control.updateSync({
3460
3704
  checkedAt,