@ours.network/cowork 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -41,3 +41,4 @@ ours-cowork docs web
41
41
  ```
42
42
 
43
43
  Before production use, read the limitations topic. In particular, backups require a stopped daemon and restore uses the complete state directory.
44
+ Lost room identity leases are recovered automatically with a non-force bind and exact persisted-CID proof. Operators can invoke the same safe path explicitly with `ours-cowork room rebind <room-id>`; it never recreates an established identity or steals a live lease.
package/dist/cli.js CHANGED
@@ -5053,6 +5053,7 @@ var NATIVE_MUTATION_METHODS = /* @__PURE__ */ new Set([
5053
5053
  "room.close",
5054
5054
  "room.recover",
5055
5055
  "room.recover.confirm",
5056
+ "room.rebind",
5056
5057
  "room.participant.remove",
5057
5058
  "room.participant.replace"
5058
5059
  ]);
@@ -5105,6 +5106,7 @@ Room commands:
5105
5106
  close <room-id>
5106
5107
  delete <room-id> --yes
5107
5108
  recover <room-id> [--confirm <old-invite-id> <new-invite-id>]
5109
+ rebind <room-id>
5108
5110
 
5109
5111
  Run \u2018ours-cowork docs\u2019 for the offline documentation index.`;
5110
5112
  }
@@ -5349,6 +5351,11 @@ async function roomRequest(command, args) {
5349
5351
  exactPositionals(parsed, 1, "room recover");
5350
5352
  return { method: "room.recover", params: { room_id: roomId } };
5351
5353
  }
5354
+ case "rebind": {
5355
+ const parsed = parseOptions(args, []);
5356
+ const [roomId] = exactPositionals(parsed, 1, "room rebind");
5357
+ return { method: "room.rebind", params: { room_id: roomId } };
5358
+ }
5352
5359
  default:
5353
5360
  usageError(`unknown room command: ${command}`);
5354
5361
  }
package/dist/daemon.js CHANGED
@@ -5267,6 +5267,14 @@ import { createHash as createHash2 } from "node:crypto";
5267
5267
  import * as fs from "node:fs";
5268
5268
  import { join as join2 } from "node:path";
5269
5269
  import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
5270
+ function sdkErrorCode(error) {
5271
+ if (error === null || typeof error !== "object" || !("code" in error)) return void 0;
5272
+ return typeof error.code === "string" ? error.code : void 0;
5273
+ }
5274
+ function isTransientTransportError(error) {
5275
+ if (error === null || typeof error !== "object" || !("code" in error)) return false;
5276
+ return (/* @__PURE__ */ new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"])).has(String(error.code));
5277
+ }
5270
5278
  function sendResult(result) {
5271
5279
  if (result.kind === "refused" || result.kind === "migrating") {
5272
5280
  return { status: "send_failed" };
@@ -5386,7 +5394,7 @@ function unpackInvite(encoded, maximumBytes) {
5386
5394
  if (compressed.length === 0) throw new Error("the invite blob is empty or invalid base64url");
5387
5395
  return Buffer.from(maximumBytes === void 0 ? brotliDecompressSync(compressed) : brotliDecompressSync(compressed, { maxOutputLength: maximumBytes }));
5388
5396
  }
5389
- var LegacyCoworkStateError, PacketRegistry, SdkRoomPacket;
5397
+ var LegacyCoworkStateError, RoomIdentityMismatchError, PacketRegistry, SdkRoomPacket;
5390
5398
  var init_packets = __esm({
5391
5399
  "src/packets.ts"() {
5392
5400
  "use strict";
@@ -5399,6 +5407,12 @@ var init_packets = __esm({
5399
5407
  this.name = "LegacyCoworkStateError";
5400
5408
  }
5401
5409
  };
5410
+ RoomIdentityMismatchError = class extends Error {
5411
+ constructor(expected, found) {
5412
+ super(`room identity CID mismatch during rebind: expected "${expected}", found "${found}"`);
5413
+ this.name = "RoomIdentityMismatchError";
5414
+ }
5415
+ };
5402
5416
  PacketRegistry = class {
5403
5417
  packets = /* @__PURE__ */ new Map();
5404
5418
  trackers = /* @__PURE__ */ new Map();
@@ -5407,6 +5421,8 @@ var init_packets = __esm({
5407
5421
  fs;
5408
5422
  log;
5409
5423
  onNotify;
5424
+ rebindSleep;
5425
+ rebindRandom;
5410
5426
  unsubscribe;
5411
5427
  constructor(host, stateDir, options = {}) {
5412
5428
  this.host = host;
@@ -5416,6 +5432,8 @@ var init_packets = __esm({
5416
5432
  });
5417
5433
  this.onNotify = options.onNotify ?? (() => {
5418
5434
  });
5435
+ this.rebindSleep = options.rebindSleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
5436
+ this.rebindRandom = options.rebindRandom ?? Math.random;
5419
5437
  this.unsubscribe = host.onIdentityNotify((name) => {
5420
5438
  const found = [...this.packets.entries()].find(([, packet2]) => packet2.name === name);
5421
5439
  if (!found) return;
@@ -5453,7 +5471,12 @@ var init_packets = __esm({
5453
5471
  localAutoAccept: true
5454
5472
  });
5455
5473
  const cid = created.info.cid;
5456
- const packet = new SdkRoomPacket(identityName, cid, client);
5474
+ const packet = new SdkRoomPacket(identityName, cid, client, {
5475
+ roomId,
5476
+ log: this.log,
5477
+ sleep: this.rebindSleep,
5478
+ random: this.rebindRandom
5479
+ });
5457
5480
  await packet.refresh();
5458
5481
  this.packets.set(roomId, packet);
5459
5482
  this.track(roomId, identityName);
@@ -5482,7 +5505,12 @@ var init_packets = __esm({
5482
5505
  if (expectedCid !== void 0 && bound.cid !== expectedCid) {
5483
5506
  throw new Error(`restored room identity CID mismatch: expected "${expectedCid}", found "${bound.cid}"`);
5484
5507
  }
5485
- const packet = new SdkRoomPacket(identityName, bound.cid, client);
5508
+ const packet = new SdkRoomPacket(identityName, bound.cid, client, {
5509
+ roomId,
5510
+ log: this.log,
5511
+ sleep: this.rebindSleep,
5512
+ random: this.rebindRandom
5513
+ });
5486
5514
  await packet.refresh();
5487
5515
  this.packets.set(roomId, packet);
5488
5516
  this.track(roomId, identityName);
@@ -5493,6 +5521,21 @@ var init_packets = __esm({
5493
5521
  throw error;
5494
5522
  }
5495
5523
  }
5524
+ async rebind(roomId) {
5525
+ validateRoomId(roomId);
5526
+ const packet = this.packets.get(roomId);
5527
+ if (!packet) throw new Error(`room packet "${roomId}" is not hosted`);
5528
+ return packet.rebind();
5529
+ }
5530
+ /** Release one local SDK lease without deleting the persisted daemon identity. */
5531
+ async unhost(roomId) {
5532
+ validateRoomId(roomId);
5533
+ const packet = this.packets.get(roomId);
5534
+ if (!packet) return;
5535
+ this.untrack(roomId);
5536
+ this.packets.delete(roomId);
5537
+ await packet.close();
5538
+ }
5496
5539
  async destroy(roomId) {
5497
5540
  validateRoomId(roomId);
5498
5541
  const packet = this.packets.get(roomId);
@@ -5549,28 +5592,87 @@ var init_packets = __esm({
5549
5592
  name;
5550
5593
  cid;
5551
5594
  client;
5595
+ roomId;
5596
+ log;
5597
+ rebindSleep;
5598
+ rebindRandom;
5599
+ rebindWork;
5552
5600
  contacts = [];
5553
5601
  hasInviteProvenance = false;
5554
5602
  invites = [];
5555
5603
  refreshWork;
5556
5604
  contactRefreshWork;
5557
- constructor(name, cid, client) {
5605
+ constructor(name, cid, client, recovery = {}) {
5606
+ this.roomId = recovery.roomId ?? name;
5558
5607
  this.name = name;
5559
5608
  this.cid = cid;
5560
5609
  this.client = client;
5610
+ this.log = recovery.log ?? (() => {
5611
+ });
5612
+ this.rebindSleep = recovery.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
5613
+ this.rebindRandom = recovery.random ?? Math.random;
5614
+ }
5615
+ rebind() {
5616
+ this.rebindWork ??= this.rebindUnlocked().finally(() => {
5617
+ this.rebindWork = void 0;
5618
+ });
5619
+ return this.rebindWork;
5620
+ }
5621
+ async rebindUnlocked() {
5622
+ this.observe("identity_rebind_detected");
5623
+ let lastError;
5624
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
5625
+ this.observe("identity_rebind_attempt", { attempt });
5626
+ try {
5627
+ const bound = await this.client.chooseIdentity({ name: this.name, force: false });
5628
+ if (bound.cid !== this.cid) {
5629
+ throw new RoomIdentityMismatchError(this.cid, bound.cid);
5630
+ }
5631
+ await this.refreshUnlocked();
5632
+ this.observe("identity_rebind_succeeded", { attempt });
5633
+ return { name: this.name, cid: this.cid, status: "rebound" };
5634
+ } catch (error) {
5635
+ lastError = error;
5636
+ const code = sdkErrorCode(error);
5637
+ if (error instanceof RoomIdentityMismatchError || !isTransientTransportError(error) || attempt === 3) {
5638
+ this.observe("identity_rebind_failed", {
5639
+ attempt,
5640
+ code: code ?? (error instanceof RoomIdentityMismatchError ? "CID_MISMATCH" : "RECOVERY_FAILED")
5641
+ });
5642
+ throw error;
5643
+ }
5644
+ const delay_ms = Math.round(100 * 2 ** (attempt - 1) * (0.75 + this.rebindRandom() * 0.5));
5645
+ this.observe("identity_rebind_retry", { attempt, delay_ms });
5646
+ await this.rebindSleep(delay_ms);
5647
+ }
5648
+ }
5649
+ throw lastError;
5650
+ }
5651
+ async runBound(operation) {
5652
+ try {
5653
+ return await operation();
5654
+ } catch (error) {
5655
+ const code = sdkErrorCode(error);
5656
+ if (code !== "NOT_BOUND" && code !== "BINDING_REASSIGNED") throw error;
5657
+ await this.rebind();
5658
+ return operation();
5659
+ }
5660
+ }
5661
+ observe(event, detail = {}) {
5662
+ this.log(JSON.stringify({ event, room_id: this.roomId, identity_name: this.name, identity_cid: this.cid, ...detail }));
5561
5663
  }
5562
5664
  refresh() {
5563
- this.refreshWork ??= this.refreshUnlocked().finally(() => {
5665
+ this.refreshWork ??= this.runBound(() => this.refreshUnlocked()).finally(() => {
5564
5666
  this.refreshWork = void 0;
5565
5667
  });
5566
5668
  return this.refreshWork;
5567
5669
  }
5568
5670
  async refreshUnlocked() {
5569
- await this.refreshContacts();
5671
+ await this.refreshContactsUnlocked();
5570
5672
  this.invites = (await this.client.listInvites()).flatMap((invite) => invite.mode === "one_time" || invite.mode === "public" ? [{ invite_id: invite.invite_id, mode: invite.mode }] : []);
5571
5673
  }
5572
5674
  refreshContacts() {
5573
- this.contactRefreshWork ??= this.refreshContactsUnlocked().finally(() => {
5675
+ this.contactRefreshWork ??= this.runBound(() => this.refreshContactsUnlocked()).finally(() => {
5574
5676
  this.contactRefreshWork = void 0;
5575
5677
  });
5576
5678
  return this.contactRefreshWork;
@@ -5584,13 +5686,13 @@ var init_packets = __esm({
5584
5686
  }));
5585
5687
  }
5586
5688
  async mintInvite(mode) {
5587
- const result = await this.client.generateInvite({ mode });
5689
+ const result = await this.runBound(() => this.client.generateInvite({ mode }));
5588
5690
  await this.refresh();
5589
5691
  return { blob: result.blob, invite_id: result.inviteId, reusable: mode === "public" };
5590
5692
  }
5591
5693
  async addContact(invite) {
5592
5694
  const decoded = unpackInvite(invite, MAX_EXTERNAL_INVITE_BYTES);
5593
- const result = await this.client.addContact({ invite });
5695
+ const result = await this.runBound(() => this.client.addContact({ invite }));
5594
5696
  await this.refresh();
5595
5697
  return {
5596
5698
  invite_id: createHash2("sha256").update(decoded).digest("hex"),
@@ -5600,7 +5702,7 @@ var init_packets = __esm({
5600
5702
  };
5601
5703
  }
5602
5704
  async revokeInvite(inviteId) {
5603
- const result = await this.client.revokeInvite({ invite_id: inviteId });
5705
+ const result = await this.runBound(() => this.client.revokeInvite({ invite_id: inviteId }));
5604
5706
  await this.refresh();
5605
5707
  return { revoked: result.revoked };
5606
5708
  }
@@ -5615,9 +5717,9 @@ var init_packets = __esm({
5615
5717
  }
5616
5718
  async listUnreadMessages(limit) {
5617
5719
  validateBatchLimit(limit);
5618
- const metadata = (await this.client.listIncomingMessages()).filter((message) => message.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
5720
+ const metadata = (await this.runBound(() => this.client.listIncomingMessages())).filter((message) => message.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
5619
5721
  return Promise.all(metadata.map(async (listed) => {
5620
- const history = await this.client.getHistoryItem({ wire_id: listed.wire_id });
5722
+ const history = await this.runBound(() => this.client.getHistoryItem({ wire_id: listed.wire_id }));
5621
5723
  if (history === null) throw new Error(`SDK history is missing unread message ${listed.wire_id}`);
5622
5724
  assertListedMessage(listed, history);
5623
5725
  return messageItem(history);
@@ -5625,7 +5727,7 @@ var init_packets = __esm({
5625
5727
  }
5626
5728
  async acknowledgeMessage(expected, onUnexpected) {
5627
5729
  for (; ; ) {
5628
- const pulled = await this.client.getMessages({ limit: 1 });
5730
+ const pulled = await this.runBound(() => this.client.getMessages({ limit: 1 }));
5629
5731
  if (pulled.messages.length > 1) throw new Error("SDK returned more than one message for limit 1");
5630
5732
  const [history] = pulled.messages;
5631
5733
  if (history === void 0) return;
@@ -5639,31 +5741,31 @@ var init_packets = __esm({
5639
5741
  }
5640
5742
  async listUnreadFiles(limit) {
5641
5743
  validateBatchLimit(limit);
5642
- const unread = (await this.client.listIncomingFiles()).filter((file) => file.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
5643
- return Promise.all(unread.map(async (file) => fileItem(file, await this.client.fetchFile(file.wire_id))));
5744
+ const unread = (await this.runBound(() => this.client.listIncomingFiles())).filter((file) => file.status === "unread").sort((left, right) => left.seq - right.seq).slice(0, limit);
5745
+ return Promise.all(unread.map(async (file) => fileItem(file, await this.runBound(() => this.client.fetchFile(file.wire_id)))));
5644
5746
  }
5645
5747
  async acknowledgeFile(expected) {
5646
- const pulled = await this.client.getFiles({ wire_ids: [expected.wire_id] });
5748
+ const pulled = await this.runBound(() => this.client.getFiles({ wire_ids: [expected.wire_id] }));
5647
5749
  if (pulled.files.length !== 1) {
5648
5750
  throw new Error(`SDK did not acknowledge selected file ${expected.wire_id}`);
5649
5751
  }
5650
5752
  assertReceivedFile(expected, pulled.files[0]);
5651
5753
  }
5652
5754
  async send(contactCid, body, replyTo) {
5653
- return sendResult(await this.client.sendMessage({
5755
+ return sendResult(await this.runBound(() => this.client.sendMessage({
5654
5756
  contact: contactCid,
5655
5757
  text: body,
5656
5758
  ...replyTo === void 0 ? {} : {
5657
5759
  reply_to_wire_id: replyTo.wire_id,
5658
5760
  ...replyTo.sentence === void 0 ? {} : { reply_to_sentence: replyTo.sentence }
5659
5761
  }
5660
- }));
5762
+ })));
5661
5763
  }
5662
5764
  async sendFile(contactCid, filename, mime, data, replyTo) {
5663
5765
  const validName = FileNameSchema.parse(filename);
5664
5766
  const validMime = FileMimeSchema.parse(mime);
5665
5767
  if (data.length > MAX_FILE_BYTES) throw new RangeError(`room files must be at most ${MAX_FILE_BYTES} bytes (2 MiB)`);
5666
- return sendResult(await this.client.sendFile({
5768
+ return sendResult(await this.runBound(() => this.client.sendFile({
5667
5769
  contact: contactCid,
5668
5770
  data_base64: data.toString("base64"),
5669
5771
  filename: validName,
@@ -5672,10 +5774,10 @@ var init_packets = __esm({
5672
5774
  reply_to_wire_id: replyTo.wire_id,
5673
5775
  ...replyTo.sentence === void 0 ? {} : { reply_to_sentence: replyTo.sentence }
5674
5776
  }
5675
- }));
5777
+ })));
5676
5778
  }
5677
5779
  async removeContact(contactCid) {
5678
- const result = await this.client.removeContact({ contact: contactCid });
5780
+ const result = await this.runBound(() => this.client.removeContact({ contact: contactCid }));
5679
5781
  const notified = result.notified === true;
5680
5782
  this.contacts = this.contacts.filter((contact) => contact.container_id !== contactCid);
5681
5783
  return { status: notified ? "queued" : "send_failed", notified, key_material_retained: true };
@@ -6392,6 +6494,29 @@ var init_service = __esm({
6392
6494
  const id = LowerCrockfordUlidSchema.parse(roomId);
6393
6495
  return this.lock(id, async () => this.recoverPacketUnlocked(id, await this.store.load(id)));
6394
6496
  }
6497
+ /** Canonical operator recovery for an established room identity lease. */
6498
+ async rebindIdentity(roomId) {
6499
+ const id = LowerCrockfordUlidSchema.parse(roomId);
6500
+ let room = await this.store.load(id);
6501
+ if (room.state === "closed" || room.state === "closing") {
6502
+ throw new RoomServiceError(`cannot rebind room "${id}" while it is ${room.state}`);
6503
+ }
6504
+ if (this.isPacketPending(room) || !this.packets.get(id)) {
6505
+ room = await this.recoverPacket(id);
6506
+ } else {
6507
+ if (!this.packets.rebind) throw new RoomServiceError("room packet registry does not support explicit rebind");
6508
+ await this.packets.rebind(id);
6509
+ }
6510
+ await this.reconcileRoom(id);
6511
+ await this.resumePending(id);
6512
+ return {
6513
+ room_id: id,
6514
+ identity_name: room.identity_name,
6515
+ identity_cid: room.identity_cid,
6516
+ status: "rebound",
6517
+ fanout: "resumed"
6518
+ };
6519
+ }
6395
6520
  async recoverPacketUnlocked(id, initial) {
6396
6521
  let room = initial;
6397
6522
  if (room.state === "closed") return room;
@@ -7074,6 +7199,11 @@ var init_service = __esm({
7074
7199
  if (room.state === "closed") {
7075
7200
  return this.store.save(room);
7076
7201
  }
7202
+ if (this.isPacketPending(room)) {
7203
+ throw new RoomServiceError(
7204
+ `cannot close room "${id}" while its identity CID is unproven; run room rebind first, or verify and remove a colliding orphan identity before retrying recovery`
7205
+ );
7206
+ }
7077
7207
  if (room.state === "closing") {
7078
7208
  room = await this.store.save(room);
7079
7209
  } else {
@@ -8765,6 +8895,14 @@ var init_openapi = __esm({
8765
8895
  result: "The updated room record with the confirmed recovery.",
8766
8896
  example: { room_id: EXAMPLE_ROOM_ID, recovery_of: "inv-01", invite_id: "inv-02" }
8767
8897
  },
8898
+ {
8899
+ method: "room.rebind",
8900
+ summary: "Rebind an established room identity",
8901
+ description: "Safely restores the exact persisted room identity with a non-force lease claim and CID proof, then resumes durable room fanout. It never creates or renames an identity.",
8902
+ params: params({ room_id: roomIdProperty }, ["room_id"]),
8903
+ result: "A structured rebind receipt after identity proof, reconciliation, and pending fanout resumption.",
8904
+ example: { room_id: EXAMPLE_ROOM_ID }
8905
+ },
8768
8906
  {
8769
8907
  method: "room.list",
8770
8908
  summary: "List rooms",
@@ -9298,6 +9436,7 @@ function createServiceRoutes(service) {
9298
9436
  const value = RecoverConfirmParams.parse(params2);
9299
9437
  return service.confirmRecoveredInvite(value.room_id, value.recovery_of, value.invite_id);
9300
9438
  } },
9439
+ "room.rebind": { auth: true, run: (params2) => service.rebindIdentity(RoomIdParams.parse(params2).room_id) },
9301
9440
  "room.list": { auth: true, run: (params2) => {
9302
9441
  external_exports.object({}).strict().parse(params2);
9303
9442
  return service.listRooms();
@@ -10330,21 +10469,43 @@ var init_daemon_runtime = __esm({
10330
10469
  const rooms = await this.store.list();
10331
10470
  this.checkpoint();
10332
10471
  const recoverable = rooms.filter((room) => room.state !== "closed");
10472
+ const healthy = new Set(recoverable.map((room) => room.room_id));
10473
+ const recoverPhase = async (roomId, phase, work) => {
10474
+ if (!healthy.has(roomId)) return;
10475
+ try {
10476
+ await work();
10477
+ this.checkpoint();
10478
+ } catch (error) {
10479
+ healthy.delete(roomId);
10480
+ const unhost = this.registry?.unhost;
10481
+ if (unhost) {
10482
+ await unhost.call(this.registry, roomId).catch((unhostError) => {
10483
+ this.options.log?.(JSON.stringify({
10484
+ event: "startup_room_unhost_failed",
10485
+ room_id: roomId,
10486
+ error: unhostError instanceof Error ? unhostError.message : String(unhostError)
10487
+ }));
10488
+ });
10489
+ }
10490
+ this.options.log?.(JSON.stringify({
10491
+ event: "startup_room_recovery_failed",
10492
+ room_id: roomId,
10493
+ phase,
10494
+ error: error instanceof Error ? error.message : String(error)
10495
+ }));
10496
+ }
10497
+ };
10333
10498
  for (const room of recoverable) {
10334
- await this.service.recoverPacket(room.room_id);
10335
- this.checkpoint();
10499
+ await recoverPhase(room.room_id, "restore", () => this.service.recoverPacket(room.room_id));
10336
10500
  }
10337
10501
  for (const room of recoverable.filter((candidate) => candidate.state !== "closing")) {
10338
- await this.service.reconcileRoom(room.room_id);
10339
- this.checkpoint();
10502
+ await recoverPhase(room.room_id, "reconcile", () => this.service.reconcileRoom(room.room_id));
10340
10503
  }
10341
10504
  for (const room of recoverable.filter((candidate) => candidate.state === "closing")) {
10342
- await this.service.closeRoom(room.room_id);
10343
- this.checkpoint();
10505
+ await recoverPhase(room.room_id, "close", () => this.service.closeRoom(room.room_id));
10344
10506
  }
10345
10507
  for (const room of recoverable.filter((candidate) => candidate.state !== "closing")) {
10346
- await this.service.resumePending(room.room_id);
10347
- this.checkpoint();
10508
+ await recoverPhase(room.room_id, "fanout", () => this.service.resumePending(room.room_id));
10348
10509
  }
10349
10510
  const realService = this.service;
10350
10511
  const serviceRoutes = createServiceRoutes(realService);
@@ -14,4 +14,8 @@ Update the display name with `ours-cowork room settings <room-id> --name "New na
14
14
 
15
15
  Close with `ours-cowork room close <room-id>`. Close is forward-only and removes the live standard SDK room identity while retaining the local archive. Archive deletion is a separate explicit operation described in the limitations topic.
16
16
 
17
+ If the shared daemon restarts or a room SDK lease is lost, Cowork automatically attempts a non-force rebind of the room's exact persisted identity name and verifies its pinned CID before resuming the rejected operation. Concurrent recovery joins one attempt; transient connection failures use bounded exponential backoff. A live competing session, missing identity, or CID mismatch fails closed and is never force-bound or recreated. Startup isolates rooms that cannot be safely restored, releases their local leases, and continues serving healthy rooms.
18
+
19
+ Use `ours-cowork room rebind <room-id>` for explicit recovery. This canonical operation applies the same name-and-CID proof, refreshes SDK contact/invite state, reconciles the room, and resumes durable pending fanout before reporting success. It refuses closing or closed rooms. Structured daemon logs emit `identity_rebind_*` and `startup_room_recovery_failed` events for diagnosis.
20
+
17
21
  Every web action has an equivalent CLI fallback in the room commands above and in the invites and messaging topics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/cowork",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
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",