@ours.network/cowork 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4562,6 +4562,7 @@ var SeatSchema = external_exports.object({
4562
4562
  participant_id: LowerCrockfordUlidSchema,
4563
4563
  state: SeatStateSchema,
4564
4564
  alias: NonEmptyStringSchema.optional(),
4565
+ removal_reason: external_exports.literal("contact_absent").optional(),
4565
4566
  removed_at: Rfc3339Schema.optional(),
4566
4567
  removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
4567
4568
  // Read and discard prerelease successor lineage. New rooms expose only
@@ -4575,7 +4576,7 @@ var SeatSchema = external_exports.object({
4575
4576
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
4576
4577
  }
4577
4578
  }
4578
- for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at"]) {
4579
+ for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
4579
4580
  if (seat[field] !== void 0) {
4580
4581
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
4581
4582
  }
@@ -4606,7 +4607,7 @@ var SeatSchema = external_exports.object({
4606
4607
  if (seat.accepted_at === void 0) {
4607
4608
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
4608
4609
  }
4609
- for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
4610
+ for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
4610
4611
  if (seat[field] !== void 0) {
4611
4612
  context.addIssue({
4612
4613
  code: external_exports.ZodIssueCode.custom,
package/dist/daemon.js CHANGED
@@ -11594,7 +11594,7 @@ var init_ours_runtime = __esm({
11594
11594
  }
11595
11595
  for (let settled = await step; !settled.done; settled = await step) {
11596
11596
  backoffMs = WATCH_RETRY_MIN_MS;
11597
- this.announce(identityName);
11597
+ this.announce(identityName, settled.value);
11598
11598
  step = stream.next();
11599
11599
  }
11600
11600
  if (!signal.aborted) {
@@ -11611,10 +11611,10 @@ var init_ours_runtime = __esm({
11611
11611
  backoffMs = Math.min(backoffMs * 2, WATCH_RETRY_MAX_MS);
11612
11612
  }
11613
11613
  }
11614
- announce(identityName) {
11614
+ announce(identityName, event) {
11615
11615
  for (const listener of this.listeners) {
11616
11616
  try {
11617
- listener(identityName);
11617
+ listener(identityName, event);
11618
11618
  } catch (error) {
11619
11619
  this.log(`cowork SDK notification listener failed for ${identityName}:`, error);
11620
11620
  }
@@ -12168,6 +12168,7 @@ var init_contracts = __esm({
12168
12168
  participant_id: LowerCrockfordUlidSchema,
12169
12169
  state: SeatStateSchema,
12170
12170
  alias: NonEmptyStringSchema.optional(),
12171
+ removal_reason: external_exports.literal("contact_absent").optional(),
12171
12172
  removed_at: Rfc3339Schema.optional(),
12172
12173
  removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
12173
12174
  // Read and discard prerelease successor lineage. New rooms expose only
@@ -12181,7 +12182,7 @@ var init_contracts = __esm({
12181
12182
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
12182
12183
  }
12183
12184
  }
12184
- for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at"]) {
12185
+ for (const field of ["accepted_at", "removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
12185
12186
  if (seat[field] !== void 0) {
12186
12187
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
12187
12188
  }
@@ -12212,7 +12213,7 @@ var init_contracts = __esm({
12212
12213
  if (seat.accepted_at === void 0) {
12213
12214
  context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
12214
12215
  }
12215
- for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
12216
+ for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
12216
12217
  if (seat[field] !== void 0) {
12217
12218
  context.addIssue({
12218
12219
  code: external_exports.ZodIssueCode.custom,
@@ -13790,12 +13791,13 @@ var init_packets = __esm({
13790
13791
  });
13791
13792
  this.rebindSleep = options.rebindSleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
13792
13793
  this.rebindRandom = options.rebindRandom ?? Math.random;
13793
- this.unsubscribe = host.onIdentityNotify((name) => {
13794
+ this.unsubscribe = host.onIdentityNotify((name, event) => {
13794
13795
  const found = [...this.packets.entries()].find(([, packet2]) => packet2.name === name);
13795
13796
  if (!found) return;
13796
13797
  const [roomId, packet] = found;
13798
+ if (event?.event === "contact_removed" && event.identity_cid !== packet.cid) return;
13797
13799
  void packet.refresh().then(
13798
- () => this.onNotify(roomId, "message_received"),
13800
+ () => this.onNotify(roomId, event?.event === "contact_removed" ? "contact_removed" : "message_received"),
13799
13801
  (error) => this.log(`[${name}] failed to refresh SDK state after notification:`, error)
13800
13802
  );
13801
13803
  });
@@ -15043,6 +15045,17 @@ var init_intake = __esm({
15043
15045
  const room = await this.store.load(roomId);
15044
15046
  const activeCids = new Set(room.seats.filter((seat) => seat.state === "active").map((seat) => seat.identity));
15045
15047
  const removedCids = new Set(room.seats.filter((seat) => seat.state === "removed").map((seat) => seat.identity));
15048
+ let firstDeliveryError;
15049
+ let deliveryFailed = false;
15050
+ const attempt = async (send) => {
15051
+ try {
15052
+ return await send();
15053
+ } catch (error) {
15054
+ if (!deliveryFailed) firstDeliveryError = error;
15055
+ deliveryFailed = true;
15056
+ return void 0;
15057
+ }
15058
+ };
15046
15059
  let after = 0;
15047
15060
  for (; ; ) {
15048
15061
  const pending = await queryStore(this.store, roomId, {
@@ -15051,7 +15064,10 @@ var init_intake = __esm({
15051
15064
  after,
15052
15065
  limit: JOURNAL_WORK_BATCH_SIZE
15053
15066
  });
15054
- if (pending.length === 0) return;
15067
+ if (pending.length === 0) {
15068
+ if (deliveryFailed) throw firstDeliveryError;
15069
+ return;
15070
+ }
15055
15071
  for (const intent of pending) {
15056
15072
  after = intent.seq;
15057
15073
  const [message] = intent.message_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "message", messageId: intent.message_id, limit: 1 });
@@ -15098,7 +15114,7 @@ var init_intake = __esm({
15098
15114
  const replyTo = decision.replyTo;
15099
15115
  if (file !== void 0) {
15100
15116
  const uploader = file.author_alias?.alias ?? file.author.display_name;
15101
- const notice = await sendRoomBody(packet, intent.recipient_identity, {
15117
+ const notice = await attempt(() => sendRoomBody(packet, intent.recipient_identity, {
15102
15118
  version: 1,
15103
15119
  kind: "room_msg",
15104
15120
  room_id: roomId,
@@ -15111,7 +15127,8 @@ var init_intake = __esm({
15111
15127
  },
15112
15128
  text: `${uploader} sent a file`,
15113
15129
  at: file.at
15114
- }, replyTo);
15130
+ }, replyTo));
15131
+ if (notice === void 0) continue;
15115
15132
  if (notice.status === "send_failed") {
15116
15133
  const failed = await this.store.append(roomId, {
15117
15134
  version: 1,
@@ -15126,13 +15143,14 @@ var init_intake = __esm({
15126
15143
  if (failed.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
15127
15144
  continue;
15128
15145
  }
15129
- const outcome2 = await packet.sendFile(
15146
+ const outcome2 = await attempt(() => packet.sendFile(
15130
15147
  intent.recipient_identity,
15131
15148
  file.filename,
15132
15149
  file.mime,
15133
15150
  Buffer.from(file.data_base64, "base64"),
15134
15151
  replyTo
15135
- );
15152
+ ));
15153
+ if (outcome2 === void 0) continue;
15136
15154
  const appended2 = await this.store.append(roomId, {
15137
15155
  version: 1,
15138
15156
  kind: "relay_result",
@@ -15167,7 +15185,8 @@ var init_intake = __esm({
15167
15185
  ...message.briefing_version === void 0 ? {} : { briefing_version: message.briefing_version },
15168
15186
  ...message.membership === void 0 ? {} : { membership: message.membership }
15169
15187
  };
15170
- const outcome = await sendRoomBody(packet, intent.recipient_identity, unsigned, replyTo);
15188
+ const outcome = await attempt(() => sendRoomBody(packet, intent.recipient_identity, unsigned, replyTo));
15189
+ if (outcome === void 0) continue;
15171
15190
  const appended = await this.store.append(roomId, {
15172
15191
  version: 1,
15173
15192
  kind: "relay_result",
@@ -16992,6 +17011,7 @@ var init_service = __esm({
16992
17011
  }
16993
17012
  async reconcileUnlocked(room, packet) {
16994
17013
  if (room.state === "closed" || room.state === "closing") return room;
17014
+ await packet.refreshContacts();
16995
17015
  const contactsByCid = /* @__PURE__ */ new Map();
16996
17016
  for (const contact of packet.listContacts()) {
16997
17017
  if (contactsByCid.has(contact.container_id)) continue;
@@ -17001,6 +17021,27 @@ var init_service = __esm({
17001
17021
  ...authenticatedInvite === void 0 ? {} : { inviteId: authenticatedInvite }
17002
17022
  });
17003
17023
  }
17024
+ let epoch = room.membership_epoch;
17025
+ const departed = /* @__PURE__ */ new Set();
17026
+ const reconciledSeats = room.seats.map((seat) => {
17027
+ if (seat.state !== "active" || contactsByCid.has(seat.identity)) return seat;
17028
+ departed.add(seat.identity);
17029
+ return {
17030
+ ...seat,
17031
+ state: "removed",
17032
+ removed_at: this.now(),
17033
+ removed_epoch: ++epoch,
17034
+ removal_reason: "contact_absent"
17035
+ };
17036
+ });
17037
+ if (departed.size > 0) {
17038
+ room = await this.store.save(RoomSchema.parse({
17039
+ ...room,
17040
+ seats: reconciledSeats,
17041
+ membership_epoch: epoch,
17042
+ command_grants: room.command_grants.filter((grant) => !departed.has(grant.caller_cid))
17043
+ }));
17044
+ }
17004
17045
  const inviteById = new Map(room.invites.map((invite) => [invite.invite_id, invite]));
17005
17046
  const existingCids = new Set(room.seats.filter((seat) => seat.state === "active" || seat.state === "pending").map((seat) => seat.identity));
17006
17047
  const lastRemovedAt = /* @__PURE__ */ new Map();
@@ -18994,7 +19035,7 @@ import * as nodeFs5 from "node:fs";
18994
19035
  import { join as join7 } from "node:path";
18995
19036
  import { fileURLToPath } from "node:url";
18996
19037
  function isIntakeNotification(event) {
18997
- return event === "message_received" || event === "file_received" || event === "contact_accepted" || event === "contact_added";
19038
+ return event === "message_received" || event === "file_received" || event === "contact_accepted" || event === "contact_added" || event === "contact_removed";
18998
19039
  }
18999
19040
  function createDaemonControlRoutes(control) {
19000
19041
  if (!/^[0-9a-f]{32}$/.test(control.session)) throw new TypeError("invalid daemon control session");
@@ -115,3 +115,17 @@ Use the RPC arguments without `room_id`; the receiving room fixes the target. Fo
115
115
  Host lifecycle and global room creation/listing are excluded. `room.accept` accepts invitation input through its separate grant and remains unavailable through REST. Ours close/delete return a durable accepted receipt before closing the reply channel; verify completion through management. Close retains archive/files; delete requires `confirm:true`, closes first, and erases local room data. Pending lifecycle requests resume after restart; failed requests remain visible in room metadata for explicit management retry. Missing replies do not prove a mutation failed.
116
116
 
117
117
  The SDK owns command reply delivery and its size limits. Large history pages or a single large file record may exceed that transport's capacity; use CLI/REST for those results. No additional result-omission contract is introduced.
118
+
119
+ ## Contact deletion and room membership
120
+
121
+ Cowork subscribes to SDK notifications for each hosted room identity. A typed `contact_removed` with a different local identity CID is ignored, even when the identity name matches. The room packet refreshes state and forwards the wake. Under the room mutex, reconciliation refreshes contacts again and removes only active seats whose exact peer CID is absent. The same path runs on startup, watch reconnection and the existing periodic resync, covering missed notifications and a crash between core deletion and room persistence.
122
+
123
+ A departed seat stays in durable room metadata with its participant ID, CID, role, accepted_at, invite provenance, removed_at, removed_epoch and `removal_reason: 'contact_absent'`. Each transition increments membership_epoch once and revokes that CID's explicit command grants. Accepted invite history remains accepted: a departure is not an invitation/admission failure. Pending asynchronous admissions without contacts stay pending. Removal reason deliberately does not claim peer intent: contact absence can also follow local removal or crash recovery. This persisted lifecycle record remains available even in quiet rooms and when nobody remains to receive a membership message.
124
+
125
+ Duplicate wakes and restarts do not change an already-removed seat's epoch or timestamp. An old event with a currently present/re-added contact cannot remove it. No attempt is made to infer an unobserved remove-and-readd cycle from a surviving contact snapshot. Each room identity reconciles its own contact book; deleting a contact in one room does not authorize removal in another where that contact still exists.
126
+
127
+ Future broadcasts use active membership; pending relays already addressed to departed participants use the existing `skipped_removed` outcome. If a contact disappears during delivery, independent recipients are still attempted (text, file metadata and file bytes, including later journal batches). A thrown send remains result-less and is retried under existing recovery semantics; the first error is reported after other recipients have been attempted. Storage errors stop immediately. No durable append receipt or relay outcome schema was changed. This boundary was coordinated with backlog task `0mtzfmkqra63687cd`, which remains owner of durable receipts and per-recipient outcome design.
128
+
129
+ ### Rollout
130
+
131
+ No core protocol or room-version migration is required. Existing version-2 rooms load with optional `removal_reason` absent; reconciliation fills it only on newly observed contact departures. Older Cowork versions strip this optional field on parse/save and do not perform automatic reconciliation; retain the upgraded version to preserve reason metadata. Admission and removal fields use the existing room lifecycle schema. An older SDK daemon supplies a legacy/sync wake; authoritative periodic reconciliation still works. The enriched typed event requires the accompanying SDK daemon change. No dependency bump to an unpublished package is made by this patch; release the SDK enhancement before or alongside Cowork.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/cowork",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Durable ours mission rooms on the shared ours daemon.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",