@ours.network/cowork 1.2.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 +15 -8
- package/dist/daemon.js +93 -36
- package/docs/05-room-workflow.md +58 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4455,9 +4455,15 @@ var LowerCrockfordUlidSchema = external_exports.string().regex(
|
|
|
4455
4455
|
);
|
|
4456
4456
|
var ContainerIdSchema2 = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
|
|
4457
4457
|
var RuntimeCommandNameSchema = external_exports.union([external_exports.enum(RUNTIME_COMMAND_NAMES), ConsumerCommandNameSchema]);
|
|
4458
|
+
var RuntimeCommandNamespacePatternSchema = external_exports.string().max(128).regex(/^(?:[a-z0-9][a-z0-9-]*\.)+\*$/);
|
|
4459
|
+
var RuntimeCommandGrantPatternSchema = external_exports.union([
|
|
4460
|
+
RuntimeCommandNameSchema,
|
|
4461
|
+
external_exports.literal("*"),
|
|
4462
|
+
RuntimeCommandNamespacePatternSchema
|
|
4463
|
+
]);
|
|
4458
4464
|
var RuntimeCommandGrantSchema = external_exports.object({
|
|
4459
4465
|
caller_cid: ContainerIdSchema2,
|
|
4460
|
-
command:
|
|
4466
|
+
command: RuntimeCommandGrantPatternSchema
|
|
4461
4467
|
}).strict();
|
|
4462
4468
|
function isStrictRfc3339(value) {
|
|
4463
4469
|
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
|
@@ -4506,7 +4512,7 @@ var RoleSchema = utf8Bounded("role", MAX_ROLE_BYTES);
|
|
|
4506
4512
|
var ROOM_ROLE = "room";
|
|
4507
4513
|
var RuntimeRoleCommandGrantSchema = external_exports.object({
|
|
4508
4514
|
role: RoleSchema,
|
|
4509
|
-
commands: external_exports.array(
|
|
4515
|
+
commands: external_exports.array(RuntimeCommandGrantPatternSchema).superRefine((commands, context) => {
|
|
4510
4516
|
const seen = /* @__PURE__ */ new Set();
|
|
4511
4517
|
for (const [index, command] of commands.entries()) {
|
|
4512
4518
|
if (seen.has(command)) {
|
|
@@ -4556,6 +4562,7 @@ var SeatSchema = external_exports.object({
|
|
|
4556
4562
|
participant_id: LowerCrockfordUlidSchema,
|
|
4557
4563
|
state: SeatStateSchema,
|
|
4558
4564
|
alias: NonEmptyStringSchema.optional(),
|
|
4565
|
+
removal_reason: external_exports.literal("contact_absent").optional(),
|
|
4559
4566
|
removed_at: Rfc3339Schema.optional(),
|
|
4560
4567
|
removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
|
|
4561
4568
|
// Read and discard prerelease successor lineage. New rooms expose only
|
|
@@ -4569,7 +4576,7 @@ var SeatSchema = external_exports.object({
|
|
|
4569
4576
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
|
|
4570
4577
|
}
|
|
4571
4578
|
}
|
|
4572
|
-
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"]) {
|
|
4573
4580
|
if (seat[field] !== void 0) {
|
|
4574
4581
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
|
|
4575
4582
|
}
|
|
@@ -4600,7 +4607,7 @@ var SeatSchema = external_exports.object({
|
|
|
4600
4607
|
if (seat.accepted_at === void 0) {
|
|
4601
4608
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
|
|
4602
4609
|
}
|
|
4603
|
-
for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
|
|
4610
|
+
for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
4604
4611
|
if (seat[field] !== void 0) {
|
|
4605
4612
|
context.addIssue({
|
|
4606
4613
|
code: external_exports.ZodIssueCode.custom,
|
|
@@ -5643,8 +5650,8 @@ async function roomRequest(command, args) {
|
|
|
5643
5650
|
const role = requiredFlag(parsed, "--role", "room role-command-set");
|
|
5644
5651
|
const value = requiredFlag(parsed, "--commands", "room role-command-set");
|
|
5645
5652
|
const commands = value === "none" ? [] : value.split(",");
|
|
5646
|
-
if (new Set(commands).size !== commands.length || commands.some((item) => !
|
|
5647
|
-
usageError("--commands must be a unique comma-separated list of
|
|
5653
|
+
if (new Set(commands).size !== commands.length || commands.some((item) => !RuntimeCommandGrantPatternSchema.safeParse(item).success)) {
|
|
5654
|
+
usageError("--commands must be a unique comma-separated list of command names, * or namespace.* patterns, or none");
|
|
5648
5655
|
}
|
|
5649
5656
|
return { method: "room.command.role.set", params: { room_id: roomId, role, commands } };
|
|
5650
5657
|
}
|
|
@@ -5655,8 +5662,8 @@ async function roomRequest(command, args) {
|
|
|
5655
5662
|
3,
|
|
5656
5663
|
`room ${command}`
|
|
5657
5664
|
);
|
|
5658
|
-
if (!
|
|
5659
|
-
usageError("runtime command must be a supported
|
|
5665
|
+
if (!RuntimeCommandGrantPatternSchema.safeParse(runtimeCommand).success) {
|
|
5666
|
+
usageError("runtime command must be a supported command name, * or namespace.* pattern");
|
|
5660
5667
|
}
|
|
5661
5668
|
return {
|
|
5662
5669
|
method: command === "command-grant" ? "room.command.grant" : "room.command.revoke",
|
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
|
}
|
|
@@ -11625,6 +11625,9 @@ var init_ours_runtime = __esm({
|
|
|
11625
11625
|
});
|
|
11626
11626
|
|
|
11627
11627
|
// src/command-names.ts
|
|
11628
|
+
function commandGrantMatches(pattern, command) {
|
|
11629
|
+
return pattern === command || pattern === "*" || pattern.endsWith(".*") && command.startsWith(pattern.slice(0, -1)) && command.length > pattern.length - 1;
|
|
11630
|
+
}
|
|
11628
11631
|
var SHARED_ROOM_COMMANDS, RUNTIME_COMMAND_NAMES;
|
|
11629
11632
|
var init_command_names = __esm({
|
|
11630
11633
|
"src/command-names.ts"() {
|
|
@@ -12050,7 +12053,7 @@ function refineMessageThread(message, context) {
|
|
|
12050
12053
|
});
|
|
12051
12054
|
}
|
|
12052
12055
|
}
|
|
12053
|
-
var MAX_TEXT_BYTES, MAX_FILE_BYTES, MAX_HISTORY_PAGE_BYTES, MAX_MANAGEMENT_RESPONSE_BYTES, MAX_EXTERNAL_INVITE_BYTES, MAX_FILE_NAME_BYTES, MAX_MIME_BYTES, MAX_ROLE_BYTES, MAX_ROOM_NAME_CHARACTERS, MAX_ROOM_IDENTITY_NAME_CHARACTERS, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, ContainerIdSchema2, RuntimeCommandNameSchema, RuntimeCommandGrantSchema, Rfc3339Schema, RoomNameSchema, ROOM_IDENTITY_PREFIX, MAX_ROOM_IDENTITY_TITLE_CHARACTERS, SDK_IDENTITY_NAME_FORBIDDEN, SDK_IDENTITY_NAME_RESERVED, CoworkIdentityNameError, RoleSchema, ROOM_ROLE, RuntimeRoleCommandGrantSchema, MissionTextSchema, MessageTextSchema, FileNameSchema, FileMimeSchema, RoomStateSchema, SeatStateSchema, InviteModeSchema, DEFAULT_ROLE, InviteStateSchema, RelayStatusSchema, SeatV1Schema, SeatSchema, RoomInviteSchema, MissionV1Schema, MissionSchema, RoleBriefingSchema, RoomCommonShape, RoomV1Schema, CurrentRoomSchema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, RoleBriefingSetInputSchema, RoleBriefingDeleteInputSchema, PostMessageInputSchema, PostAsRoleInputSchema, RestRoleInputSchema, AcceptExternalInviteInputSchema, ListMembersCommandInputSchema, CommandIdempotencyKeySchema, RemoveMemberCommandInputSchema, RuntimeCommandGrantInputSchema, RuntimeRoleCommandGrantInputSchema, RuntimeCommandAuditSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MembershipNoticeSchema, AuthorAliasSchema, ReplyReferenceSchema, MessageShape, RelayIntentShape, RelayResultStatusSchema, RelayResultShape, FileShape, IntakeRejectionShape, MembershipIntentShape, MembershipResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, FileRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, IntakeRejectionRecordSchema, MembershipIntentRecordSchema, MembershipResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
|
|
12056
|
+
var MAX_TEXT_BYTES, MAX_FILE_BYTES, MAX_HISTORY_PAGE_BYTES, MAX_MANAGEMENT_RESPONSE_BYTES, MAX_EXTERNAL_INVITE_BYTES, MAX_FILE_NAME_BYTES, MAX_MIME_BYTES, MAX_ROLE_BYTES, MAX_ROOM_NAME_CHARACTERS, MAX_ROOM_IDENTITY_NAME_CHARACTERS, NonEmptyStringSchema, PositiveSafeIntegerSchema, LowerCrockfordUlidSchema, ContainerIdSchema2, RuntimeCommandNameSchema, RuntimeCommandNamespacePatternSchema, RuntimeCommandGrantPatternSchema, RuntimeCommandGrantSchema, Rfc3339Schema, RoomNameSchema, ROOM_IDENTITY_PREFIX, MAX_ROOM_IDENTITY_TITLE_CHARACTERS, SDK_IDENTITY_NAME_FORBIDDEN, SDK_IDENTITY_NAME_RESERVED, CoworkIdentityNameError, RoleSchema, ROOM_ROLE, RuntimeRoleCommandGrantSchema, MissionTextSchema, MessageTextSchema, FileNameSchema, FileMimeSchema, RoomStateSchema, SeatStateSchema, InviteModeSchema, DEFAULT_ROLE, InviteStateSchema, RelayStatusSchema, SeatV1Schema, SeatSchema, RoomInviteSchema, MissionV1Schema, MissionSchema, RoleBriefingSchema, RoomCommonShape, RoomV1Schema, CurrentRoomSchema, RoomSchema, CreateRoomInputSchema, UpdateRoomInputSchema, RoleBriefingSetInputSchema, RoleBriefingDeleteInputSchema, PostMessageInputSchema, PostAsRoleInputSchema, RestRoleInputSchema, AcceptExternalInviteInputSchema, ListMembersCommandInputSchema, CommandIdempotencyKeySchema, RemoveMemberCommandInputSchema, RuntimeCommandGrantInputSchema, RuntimeRoleCommandGrantInputSchema, RuntimeCommandAuditSchema, AuthorSnapshotSchema, RecordCommonShape, AppendCommonShape, MembershipNoticeSchema, AuthorAliasSchema, ReplyReferenceSchema, MessageShape, RelayIntentShape, RelayResultStatusSchema, RelayResultShape, FileShape, IntakeRejectionShape, MembershipIntentShape, MembershipResultShape, CloseNoticeIntentShape, CloseNoticeResultShape, MessageRecordSchema, FileRecordSchema, RelayIntentRecordSchema, RelayResultRecordSchema, IntakeRejectionRecordSchema, MembershipIntentRecordSchema, MembershipResultRecordSchema, CloseNoticeIntentRecordSchema, CloseNoticeResultRecordSchema, RawCommunicationRecordSchema, CommunicationRecordSchema, AppendRecordSchema;
|
|
12054
12057
|
var init_contracts = __esm({
|
|
12055
12058
|
"src/contracts.ts"() {
|
|
12056
12059
|
"use strict";
|
|
@@ -12076,9 +12079,15 @@ var init_contracts = __esm({
|
|
|
12076
12079
|
);
|
|
12077
12080
|
ContainerIdSchema2 = external_exports.string().regex(/^[0-9a-f]{64}$/i, "must be a 64-character hexadecimal CID").transform((value) => value.toUpperCase());
|
|
12078
12081
|
RuntimeCommandNameSchema = external_exports.union([external_exports.enum(RUNTIME_COMMAND_NAMES), ConsumerCommandNameSchema]);
|
|
12082
|
+
RuntimeCommandNamespacePatternSchema = external_exports.string().max(128).regex(/^(?:[a-z0-9][a-z0-9-]*\.)+\*$/);
|
|
12083
|
+
RuntimeCommandGrantPatternSchema = external_exports.union([
|
|
12084
|
+
RuntimeCommandNameSchema,
|
|
12085
|
+
external_exports.literal("*"),
|
|
12086
|
+
RuntimeCommandNamespacePatternSchema
|
|
12087
|
+
]);
|
|
12079
12088
|
RuntimeCommandGrantSchema = external_exports.object({
|
|
12080
12089
|
caller_cid: ContainerIdSchema2,
|
|
12081
|
-
command:
|
|
12090
|
+
command: RuntimeCommandGrantPatternSchema
|
|
12082
12091
|
}).strict();
|
|
12083
12092
|
Rfc3339Schema = external_exports.string().refine(isStrictRfc3339, "must be a valid RFC3339 timestamp");
|
|
12084
12093
|
RoomNameSchema = external_exports.string().refine(
|
|
@@ -12108,7 +12117,7 @@ var init_contracts = __esm({
|
|
|
12108
12117
|
ROOM_ROLE = "room";
|
|
12109
12118
|
RuntimeRoleCommandGrantSchema = external_exports.object({
|
|
12110
12119
|
role: RoleSchema,
|
|
12111
|
-
commands: external_exports.array(
|
|
12120
|
+
commands: external_exports.array(RuntimeCommandGrantPatternSchema).superRefine((commands, context) => {
|
|
12112
12121
|
const seen = /* @__PURE__ */ new Set();
|
|
12113
12122
|
for (const [index, command] of commands.entries()) {
|
|
12114
12123
|
if (seen.has(command)) {
|
|
@@ -12159,6 +12168,7 @@ var init_contracts = __esm({
|
|
|
12159
12168
|
participant_id: LowerCrockfordUlidSchema,
|
|
12160
12169
|
state: SeatStateSchema,
|
|
12161
12170
|
alias: NonEmptyStringSchema.optional(),
|
|
12171
|
+
removal_reason: external_exports.literal("contact_absent").optional(),
|
|
12162
12172
|
removed_at: Rfc3339Schema.optional(),
|
|
12163
12173
|
removed_epoch: external_exports.number().int().nonnegative().safe().optional(),
|
|
12164
12174
|
// Read and discard prerelease successor lineage. New rooms expose only
|
|
@@ -12172,7 +12182,7 @@ var init_contracts = __esm({
|
|
|
12172
12182
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `pending seats require ${field}` });
|
|
12173
12183
|
}
|
|
12174
12184
|
}
|
|
12175
|
-
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"]) {
|
|
12176
12186
|
if (seat[field] !== void 0) {
|
|
12177
12187
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: [field], message: `${field} is forbidden on pending seats` });
|
|
12178
12188
|
}
|
|
@@ -12203,7 +12213,7 @@ var init_contracts = __esm({
|
|
|
12203
12213
|
if (seat.accepted_at === void 0) {
|
|
12204
12214
|
context.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["accepted_at"], message: "active seats require accepted_at" });
|
|
12205
12215
|
}
|
|
12206
|
-
for (const field of ["removed_at", "removed_epoch", "bounced_at"]) {
|
|
12216
|
+
for (const field of ["removed_at", "removed_epoch", "bounced_at", "removal_reason"]) {
|
|
12207
12217
|
if (seat[field] !== void 0) {
|
|
12208
12218
|
context.addIssue({
|
|
12209
12219
|
code: external_exports.ZodIssueCode.custom,
|
|
@@ -12973,7 +12983,7 @@ function apiDocsAsset(pathname) {
|
|
|
12973
12983
|
return void 0;
|
|
12974
12984
|
}
|
|
12975
12985
|
}
|
|
12976
|
-
var RPC_PATH, OPENAPI_DOCUMENT_PATH, API_DOCS_PATH, API_DOCS_SCRIPT_PATH, API_DOCS_STYLESHEET_PATH, API_VERSION, EXAMPLE_ROOM_ID, roomIdProperty, roleProperty, restRoleProperty, missionTextProperty, roomNameProperty, inviteModeProperty, notifyProperty, PRIVATE_ROOM_RPC_METHODS, ROOM_RPC_METHODS, RPC_ERROR_CODES, DOCS_PAGE, DOCS_STYLESHEET, DOCS_SCRIPT;
|
|
12986
|
+
var RPC_PATH, OPENAPI_DOCUMENT_PATH, API_DOCS_PATH, API_DOCS_SCRIPT_PATH, API_DOCS_STYLESHEET_PATH, API_VERSION, commandGrantPatternProperty, EXAMPLE_ROOM_ID, roomIdProperty, roleProperty, restRoleProperty, missionTextProperty, roomNameProperty, inviteModeProperty, notifyProperty, PRIVATE_ROOM_RPC_METHODS, ROOM_RPC_METHODS, RPC_ERROR_CODES, DOCS_PAGE, DOCS_STYLESHEET, DOCS_SCRIPT;
|
|
12977
12987
|
var init_openapi = __esm({
|
|
12978
12988
|
"src/openapi.ts"() {
|
|
12979
12989
|
"use strict";
|
|
@@ -12984,6 +12994,15 @@ var init_openapi = __esm({
|
|
|
12984
12994
|
API_DOCS_SCRIPT_PATH = "/docs/ui.js";
|
|
12985
12995
|
API_DOCS_STYLESHEET_PATH = "/docs/ui.css";
|
|
12986
12996
|
API_VERSION = "1";
|
|
12997
|
+
commandGrantPatternProperty = {
|
|
12998
|
+
anyOf: [
|
|
12999
|
+
{ type: "string", enum: [...RUNTIME_COMMAND_NAMES] },
|
|
13000
|
+
{ type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" },
|
|
13001
|
+
{ type: "string", enum: ["*"] },
|
|
13002
|
+
{ type: "string", maxLength: 128, pattern: "^(?:[a-z0-9][a-z0-9-]*\\.)+\\*$" }
|
|
13003
|
+
],
|
|
13004
|
+
description: "Exact command, * for all runtime commands, or terminal namespace.*. Patterns also cover future matching commands."
|
|
13005
|
+
};
|
|
12987
13006
|
EXAMPLE_ROOM_ID = "01jd7q4h9m2v8xk3znbc5regty";
|
|
12988
13007
|
roomIdProperty = {
|
|
12989
13008
|
type: "string",
|
|
@@ -13048,7 +13067,7 @@ var init_openapi = __esm({
|
|
|
13048
13067
|
{
|
|
13049
13068
|
method: "room.command.definition.put",
|
|
13050
13069
|
summary: "Register or replace a consumer command",
|
|
13051
|
-
description: "Registers a consumer.* command for the ours catalog using an exact host-configured handler reference. Replacement clears grants for that name. Local definitions cannot be replaced here. A published:false result means desired state is committed; use reload to retry publication.",
|
|
13070
|
+
description: "Registers a consumer.* command for the ours catalog using an exact host-configured handler reference. Replacement clears exact grants for that name; wildcard grants remain effective. Local definitions cannot be replaced here. A published:false result means desired state is committed; use reload to retry publication.",
|
|
13052
13071
|
params: params({ room_id: roomIdProperty, expected_revision: { type: "integer", minimum: 0 }, definition: {
|
|
13053
13072
|
type: "object",
|
|
13054
13073
|
additionalProperties: false,
|
|
@@ -13061,7 +13080,7 @@ var init_openapi = __esm({
|
|
|
13061
13080
|
{
|
|
13062
13081
|
method: "room.command.definition.delete",
|
|
13063
13082
|
summary: "Delete a REST consumer command",
|
|
13064
|
-
description: "Deletes the desired definition and its CID/role grants. Requires the current registry revision. Local commands must be removed from their file and reloaded.",
|
|
13083
|
+
description: "Deletes the desired definition and its exact CID/role grants; wildcard grants remain stored. Requires the current registry revision. Local commands must be removed from their file and reloaded.",
|
|
13065
13084
|
params: params({ room_id: roomIdProperty, expected_revision: { type: "integer", minimum: 0 }, name: { type: "string" } }, ["room_id", "expected_revision", "name"]),
|
|
13066
13085
|
result: "Updated revision, published and definitions.",
|
|
13067
13086
|
example: { room_id: EXAMPLE_ROOM_ID, expected_revision: 1, name: "consumer.orders" }
|
|
@@ -13231,7 +13250,7 @@ var init_openapi = __esm({
|
|
|
13231
13250
|
{
|
|
13232
13251
|
method: "room.command.grants",
|
|
13233
13252
|
summary: "List runtime-command grants",
|
|
13234
|
-
description: "Lists the default-deny grants that bind one active participant CID to one room runtime command.",
|
|
13253
|
+
description: "Lists the default-deny grants that bind one active participant CID to one room runtime command name or stored wildcard pattern.",
|
|
13235
13254
|
params: params({ room_id: roomIdProperty }, ["room_id"]),
|
|
13236
13255
|
result: "An array of `{caller_cid, command}` grants.",
|
|
13237
13256
|
example: { room_id: EXAMPLE_ROOM_ID }
|
|
@@ -13247,13 +13266,11 @@ var init_openapi = __esm({
|
|
|
13247
13266
|
{
|
|
13248
13267
|
method: "room.command.role.set",
|
|
13249
13268
|
summary: "Set a role runtime-command policy",
|
|
13250
|
-
description: "Atomically replaces the command policy for one exact role. An empty command list removes the policy without removing independent per-CID grants.",
|
|
13269
|
+
description: "Atomically replaces the command policy for one exact role. An empty command list removes the policy without removing independent per-CID grants. Accepts exact names, * and namespace.* patterns.",
|
|
13251
13270
|
params: params({
|
|
13252
13271
|
room_id: roomIdProperty,
|
|
13253
13272
|
role: { type: "string", minLength: 1, description: "Exact admitted seat role." },
|
|
13254
|
-
commands: { type: "array", uniqueItems: true, items:
|
|
13255
|
-
anyOf: [{ type: "string", enum: [...RUNTIME_COMMAND_NAMES] }, { type: "string", maxLength: 128, pattern: "^consumer\\.[a-z0-9][a-z0-9.-]*[a-z0-9]$" }]
|
|
13256
|
-
} }
|
|
13273
|
+
commands: { type: "array", uniqueItems: true, items: commandGrantPatternProperty }
|
|
13257
13274
|
}, ["room_id", "role", "commands"]),
|
|
13258
13275
|
result: "The complete sorted role-policy list after the idempotent update.",
|
|
13259
13276
|
example: { room_id: EXAMPLE_ROOM_ID, role: "Owner", commands: ["list-members", "remove-member"] }
|
|
@@ -13261,11 +13278,11 @@ var init_openapi = __esm({
|
|
|
13261
13278
|
{
|
|
13262
13279
|
method: "room.command.grant",
|
|
13263
13280
|
summary: "Authorize a runtime command caller",
|
|
13264
|
-
description: "Idempotently grants one command to one exact active participant CID. Display names and roles are never authority.",
|
|
13281
|
+
description: "Idempotently grants one command name or dynamic pattern to one exact active participant CID. Display names and caller-supplied roles are never authority. * and room.* include permission administration and room lifecycle commands; * also covers future or replaced consumer commands.",
|
|
13265
13282
|
params: params({
|
|
13266
13283
|
room_id: roomIdProperty,
|
|
13267
13284
|
caller_cid: { type: "string", pattern: "^[0-9A-Fa-f]{64}$", description: "Authenticated caller CID." },
|
|
13268
|
-
command:
|
|
13285
|
+
command: commandGrantPatternProperty
|
|
13269
13286
|
}, ["room_id", "caller_cid", "command"]),
|
|
13270
13287
|
result: "The complete sorted grant list after the idempotent update.",
|
|
13271
13288
|
example: { room_id: EXAMPLE_ROOM_ID, caller_cid: "A".repeat(64), command: "list-members" }
|
|
@@ -13273,11 +13290,11 @@ var init_openapi = __esm({
|
|
|
13273
13290
|
{
|
|
13274
13291
|
method: "room.command.revoke",
|
|
13275
13292
|
summary: "Revoke a runtime command caller",
|
|
13276
|
-
description: "Idempotently removes
|
|
13293
|
+
description: "Idempotently removes only the exact stored CID/name or CID/pattern entry; overlapping grants remain effective. Revocation is persisted before subsequent command dispatch can acquire the room mutex.",
|
|
13277
13294
|
params: params({
|
|
13278
13295
|
room_id: roomIdProperty,
|
|
13279
13296
|
caller_cid: { type: "string", pattern: "^[0-9A-Fa-f]{64}$", description: "Authenticated caller CID." },
|
|
13280
|
-
command:
|
|
13297
|
+
command: commandGrantPatternProperty
|
|
13281
13298
|
}, ["room_id", "caller_cid", "command"]),
|
|
13282
13299
|
result: "The complete sorted grant list after the idempotent update.",
|
|
13283
13300
|
example: { room_id: EXAMPLE_ROOM_ID, caller_cid: "A".repeat(64), command: "remove-member" }
|
|
@@ -13774,12 +13791,13 @@ var init_packets = __esm({
|
|
|
13774
13791
|
});
|
|
13775
13792
|
this.rebindSleep = options.rebindSleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
|
|
13776
13793
|
this.rebindRandom = options.rebindRandom ?? Math.random;
|
|
13777
|
-
this.unsubscribe = host.onIdentityNotify((name) => {
|
|
13794
|
+
this.unsubscribe = host.onIdentityNotify((name, event) => {
|
|
13778
13795
|
const found = [...this.packets.entries()].find(([, packet2]) => packet2.name === name);
|
|
13779
13796
|
if (!found) return;
|
|
13780
13797
|
const [roomId, packet] = found;
|
|
13798
|
+
if (event?.event === "contact_removed" && event.identity_cid !== packet.cid) return;
|
|
13781
13799
|
void packet.refresh().then(
|
|
13782
|
-
() => this.onNotify(roomId, "message_received"),
|
|
13800
|
+
() => this.onNotify(roomId, event?.event === "contact_removed" ? "contact_removed" : "message_received"),
|
|
13783
13801
|
(error) => this.log(`[${name}] failed to refresh SDK state after notification:`, error)
|
|
13784
13802
|
);
|
|
13785
13803
|
});
|
|
@@ -15027,6 +15045,17 @@ var init_intake = __esm({
|
|
|
15027
15045
|
const room = await this.store.load(roomId);
|
|
15028
15046
|
const activeCids = new Set(room.seats.filter((seat) => seat.state === "active").map((seat) => seat.identity));
|
|
15029
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
|
+
};
|
|
15030
15059
|
let after = 0;
|
|
15031
15060
|
for (; ; ) {
|
|
15032
15061
|
const pending = await queryStore(this.store, roomId, {
|
|
@@ -15035,7 +15064,10 @@ var init_intake = __esm({
|
|
|
15035
15064
|
after,
|
|
15036
15065
|
limit: JOURNAL_WORK_BATCH_SIZE
|
|
15037
15066
|
});
|
|
15038
|
-
if (pending.length === 0)
|
|
15067
|
+
if (pending.length === 0) {
|
|
15068
|
+
if (deliveryFailed) throw firstDeliveryError;
|
|
15069
|
+
return;
|
|
15070
|
+
}
|
|
15039
15071
|
for (const intent of pending) {
|
|
15040
15072
|
after = intent.seq;
|
|
15041
15073
|
const [message] = intent.message_id === void 0 ? [] : await queryStore(this.store, roomId, { kind: "message", messageId: intent.message_id, limit: 1 });
|
|
@@ -15082,7 +15114,7 @@ var init_intake = __esm({
|
|
|
15082
15114
|
const replyTo = decision.replyTo;
|
|
15083
15115
|
if (file !== void 0) {
|
|
15084
15116
|
const uploader = file.author_alias?.alias ?? file.author.display_name;
|
|
15085
|
-
const notice = await sendRoomBody(packet, intent.recipient_identity, {
|
|
15117
|
+
const notice = await attempt(() => sendRoomBody(packet, intent.recipient_identity, {
|
|
15086
15118
|
version: 1,
|
|
15087
15119
|
kind: "room_msg",
|
|
15088
15120
|
room_id: roomId,
|
|
@@ -15095,7 +15127,8 @@ var init_intake = __esm({
|
|
|
15095
15127
|
},
|
|
15096
15128
|
text: `${uploader} sent a file`,
|
|
15097
15129
|
at: file.at
|
|
15098
|
-
}, replyTo);
|
|
15130
|
+
}, replyTo));
|
|
15131
|
+
if (notice === void 0) continue;
|
|
15099
15132
|
if (notice.status === "send_failed") {
|
|
15100
15133
|
const failed = await this.store.append(roomId, {
|
|
15101
15134
|
version: 1,
|
|
@@ -15110,13 +15143,14 @@ var init_intake = __esm({
|
|
|
15110
15143
|
if (failed.kind !== "relay_result") throw new Error("storage returned the wrong relay result kind");
|
|
15111
15144
|
continue;
|
|
15112
15145
|
}
|
|
15113
|
-
const outcome2 = await packet.sendFile(
|
|
15146
|
+
const outcome2 = await attempt(() => packet.sendFile(
|
|
15114
15147
|
intent.recipient_identity,
|
|
15115
15148
|
file.filename,
|
|
15116
15149
|
file.mime,
|
|
15117
15150
|
Buffer.from(file.data_base64, "base64"),
|
|
15118
15151
|
replyTo
|
|
15119
|
-
);
|
|
15152
|
+
));
|
|
15153
|
+
if (outcome2 === void 0) continue;
|
|
15120
15154
|
const appended2 = await this.store.append(roomId, {
|
|
15121
15155
|
version: 1,
|
|
15122
15156
|
kind: "relay_result",
|
|
@@ -15151,7 +15185,8 @@ var init_intake = __esm({
|
|
|
15151
15185
|
...message.briefing_version === void 0 ? {} : { briefing_version: message.briefing_version },
|
|
15152
15186
|
...message.membership === void 0 ? {} : { membership: message.membership }
|
|
15153
15187
|
};
|
|
15154
|
-
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;
|
|
15155
15190
|
const appended = await this.store.append(roomId, {
|
|
15156
15191
|
version: 1,
|
|
15157
15192
|
kind: "relay_result",
|
|
@@ -15375,12 +15410,12 @@ var init_command_routes = __esm({
|
|
|
15375
15410
|
RuntimeCommandGrantParams = external_exports.object({
|
|
15376
15411
|
room_id: external_exports.string(),
|
|
15377
15412
|
caller_cid: ContainerIdSchema2,
|
|
15378
|
-
command:
|
|
15413
|
+
command: RuntimeCommandGrantPatternSchema
|
|
15379
15414
|
}).strict();
|
|
15380
15415
|
RuntimeRoleCommandGrantParams = external_exports.object({
|
|
15381
15416
|
room_id: external_exports.string(),
|
|
15382
15417
|
role: external_exports.string(),
|
|
15383
|
-
commands: external_exports.array(
|
|
15418
|
+
commands: external_exports.array(RuntimeCommandGrantPatternSchema)
|
|
15384
15419
|
}).strict();
|
|
15385
15420
|
ParticipantRemoveParams = external_exports.object({
|
|
15386
15421
|
room_id: external_exports.string(),
|
|
@@ -16265,9 +16300,9 @@ var init_service = __esm({
|
|
|
16265
16300
|
return seat;
|
|
16266
16301
|
}
|
|
16267
16302
|
hasRuntimeCommandGrant(room, callerCid, command) {
|
|
16268
|
-
if (room.command_grants.some((grant) => grant.caller_cid === callerCid && grant.command
|
|
16303
|
+
if (room.command_grants.some((grant) => grant.caller_cid === callerCid && commandGrantMatches(grant.command, command))) return true;
|
|
16269
16304
|
const caller = room.seats.find((seat) => seat.state === "active" && seat.identity === callerCid);
|
|
16270
|
-
return caller !== void 0 && room.role_command_grants.some((grant) => grant.role === caller.role && grant.commands.
|
|
16305
|
+
return caller !== void 0 && room.role_command_grants.some((grant) => grant.role === caller.role && grant.commands.some((pattern) => commandGrantMatches(pattern, command)));
|
|
16271
16306
|
}
|
|
16272
16307
|
async beginRemovalUnlocked(room, seat, notify) {
|
|
16273
16308
|
if (seat.state === "pending" || isCancelledExternalSeat(seat)) {
|
|
@@ -16575,7 +16610,7 @@ var init_service = __esm({
|
|
|
16575
16610
|
}
|
|
16576
16611
|
/** List the operator-managed runtime-command grants for one room. */
|
|
16577
16612
|
assertRegisteredConsumerName(room, name) {
|
|
16578
|
-
if (name.startsWith("consumer.") && !(room.consumer_commands ?? []).some((definition) => definition.name === name)) {
|
|
16613
|
+
if (name.startsWith("consumer.") && !name.endsWith(".*") && !(room.consumer_commands ?? []).some((definition) => definition.name === name)) {
|
|
16579
16614
|
throw new RoomServiceError("consumer command is not registered");
|
|
16580
16615
|
}
|
|
16581
16616
|
}
|
|
@@ -16719,7 +16754,7 @@ var init_service = __esm({
|
|
|
16719
16754
|
return saved.role_command_grants.map((grant) => ({ role: grant.role, commands: [...grant.commands] }));
|
|
16720
16755
|
});
|
|
16721
16756
|
}
|
|
16722
|
-
/** Grant one
|
|
16757
|
+
/** Grant one command selector to one active authenticated room identity. Idempotent. */
|
|
16723
16758
|
async grantRuntimeCommand(roomId, input) {
|
|
16724
16759
|
const id = LowerCrockfordUlidSchema.parse(roomId);
|
|
16725
16760
|
const request = RuntimeCommandGrantInputSchema.parse(input);
|
|
@@ -16738,7 +16773,7 @@ var init_service = __esm({
|
|
|
16738
16773
|
return saved.command_grants.map((grant) => ({ ...grant }));
|
|
16739
16774
|
});
|
|
16740
16775
|
}
|
|
16741
|
-
/**
|
|
16776
|
+
/** Remove only the exact stored selector; overlapping grants remain effective. */
|
|
16742
16777
|
async revokeRuntimeCommand(roomId, input) {
|
|
16743
16778
|
const id = LowerCrockfordUlidSchema.parse(roomId);
|
|
16744
16779
|
const request = RuntimeCommandGrantInputSchema.parse(input);
|
|
@@ -16976,6 +17011,7 @@ var init_service = __esm({
|
|
|
16976
17011
|
}
|
|
16977
17012
|
async reconcileUnlocked(room, packet) {
|
|
16978
17013
|
if (room.state === "closed" || room.state === "closing") return room;
|
|
17014
|
+
await packet.refreshContacts();
|
|
16979
17015
|
const contactsByCid = /* @__PURE__ */ new Map();
|
|
16980
17016
|
for (const contact of packet.listContacts()) {
|
|
16981
17017
|
if (contactsByCid.has(contact.container_id)) continue;
|
|
@@ -16985,6 +17021,27 @@ var init_service = __esm({
|
|
|
16985
17021
|
...authenticatedInvite === void 0 ? {} : { inviteId: authenticatedInvite }
|
|
16986
17022
|
});
|
|
16987
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
|
+
}
|
|
16988
17045
|
const inviteById = new Map(room.invites.map((invite) => [invite.invite_id, invite]));
|
|
16989
17046
|
const existingCids = new Set(room.seats.filter((seat) => seat.state === "active" || seat.state === "pending").map((seat) => seat.identity));
|
|
16990
17047
|
const lastRemovedAt = /* @__PURE__ */ new Map();
|
|
@@ -18978,7 +19035,7 @@ import * as nodeFs5 from "node:fs";
|
|
|
18978
19035
|
import { join as join7 } from "node:path";
|
|
18979
19036
|
import { fileURLToPath } from "node:url";
|
|
18980
19037
|
function isIntakeNotification(event) {
|
|
18981
|
-
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";
|
|
18982
19039
|
}
|
|
18983
19040
|
function createDaemonControlRoutes(control) {
|
|
18984
19041
|
if (!/^[0-9a-f]{32}$/.test(control.session)) throw new TypeError("invalid daemon control session");
|
package/docs/05-room-workflow.md
CHANGED
|
@@ -14,9 +14,51 @@ Update the display name with `ours-cowork room settings <room-id> --name "New na
|
|
|
14
14
|
|
|
15
15
|
Runtime commands are default-deny. Per-CID grants use `command-grant` and `command-revoke`. An operator may instead register a durable role policy with `role-command-set <room-id> --role <label> --commands <comma-list>` and inspect it with `role-command-grants`; use `--commands none` to remove it. A role policy authorizes only an authenticated, active seat whose durable admission role exactly matches the policy. Display names, message labels, pending seats, removed seats, and caller-supplied role text never confer authority. Removing a role policy does not remove an independently configured per-CID grant.
|
|
16
16
|
|
|
17
|
+
### Wildcard command grants
|
|
18
|
+
|
|
19
|
+
CID grants and role policies accept exact command names, `*`, and a terminal
|
|
20
|
+
`namespace.*` pattern. Quote patterns in the shell:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
ours-cowork room command-grant <room-id> <caller-cid> '*'
|
|
24
|
+
ours-cowork room command-grant <room-id> <caller-cid> 'room.*'
|
|
25
|
+
ours-cowork room role-command-set <room-id> --role Reviewer --commands 'room.briefing.*,consumer.orders.*'
|
|
26
|
+
ours-cowork room command-revoke <room-id> <caller-cid> 'room.*'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use the same strings in the `command` field of `room.command.grant` / `revoke`
|
|
30
|
+
and the `commands` array of `room.command.role.set` over RPC or ours transport.
|
|
31
|
+
Invocation still requires a concrete command name; patterns are permissions only.
|
|
32
|
+
|
|
33
|
+
`*` covers all room runtime commands, including `start_thread`, `list-members`,
|
|
34
|
+
`remove-member`, shared `room.*` commands and registered `consumer.*` commands.
|
|
35
|
+
`room.*` covers only names beginning with `room.`; it excludes those dedicated
|
|
36
|
+
commands and consumer commands. `room.command.*` includes deeper descendants.
|
|
37
|
+
Namespace segments use lowercase ASCII letters, digits and hyphens, start with a
|
|
38
|
+
letter or digit, and are separated by dots; a namespace pattern is at most 128
|
|
39
|
+
characters. Empty segments, internal stars, `room*`, `*.show`, regex, whitespace
|
|
40
|
+
and case folding are unsupported. A valid namespace need not exist yet.
|
|
41
|
+
|
|
42
|
+
Patterns are stored and listed verbatim, and match future commands dynamically.
|
|
43
|
+
Consumer replacement/deletion still clears exact grants for the changed name,
|
|
44
|
+
but retains wildcard grants: once a matching consumer is registered and published
|
|
45
|
+
again, the wildcard authorizes it. Registration and publication checks still apply.
|
|
46
|
+
|
|
47
|
+
`*` and `room.*` include permission administration and room close/delete commands,
|
|
48
|
+
so their holders can delegate privileges. Granting them requires the same management
|
|
49
|
+
access or existing permission to invoke the relevant grant command. Patterns never
|
|
50
|
+
bypass authenticated CID, active membership, room scope, lifecycle checks, or
|
|
51
|
+
participant result visibility; they do not authorize host/global management APIs.
|
|
52
|
+
|
|
53
|
+
Granting an identical CID/pattern is idempotent. Revocation removes only the exact
|
|
54
|
+
stored entry, not all matching names: revoking `room.show` does not override a retained
|
|
55
|
+
`room.*`, and revoking `room.*` leaves independent exact or role grants in effect.
|
|
56
|
+
Patterns survive restart, and per-CID grants are removed with participant removal
|
|
57
|
+
just like exact grants. Empty role command lists remove the role policy.
|
|
58
|
+
|
|
17
59
|
## Scoped reply threads
|
|
18
60
|
|
|
19
|
-
The dedicated `start_thread` runtime command creates a scoped reply thread after an operator grants
|
|
61
|
+
The dedicated `start_thread` runtime command creates a scoped reply thread after an operator grants `start_thread` or `*`. Discover and invoke it through the SDK's generic command APIs, then use the SDK's native reply field:
|
|
20
62
|
|
|
21
63
|
```ts
|
|
22
64
|
const definitions = await client.listContactCommands({ contact: roomCid });
|
|
@@ -66,10 +108,24 @@ Every web action has an equivalent CLI fallback in the room commands above and i
|
|
|
66
108
|
|
|
67
109
|
Room-scoped operations also appear in the room identity's ours catalog with their RPC names: `room.settings`, `room.briefing.role.set`, `room.briefing.role.delete`, `room.invite`, `room.participant.remove`, `room.revoke`, `room.recover`, `room.recover.confirm`, `room.show`, `room.participants`, `room.command.grants`, `room.command.role.grants`, `room.command.role.set`, `room.command.grant`, `room.command.revoke`, `room.history`, `room.message`, `room.say`, `room.role.rest.add`, `room.role.rest.remove`, `room.accept`, `room.rebind`, `room.close`, and `room.delete`.
|
|
68
110
|
|
|
69
|
-
Use the RPC arguments without `room_id`; the receiving room fixes the target. For example, an operator grants `ours-cowork room command-grant <room-id> <caller-cid> room.settings`, then that active member calls `room.settings` with `{"status":"review"}` using ours command transport.
|
|
111
|
+
Use the RPC arguments without `room_id`; the receiving room fixes the target. For example, an operator grants `ours-cowork room command-grant <room-id> <caller-cid> room.settings`, then that active member calls `room.settings` with `{"status":"review"}` using ours command transport. An exact-name grant grants none of the other names. The SDK returns a correlated result containing `{ok:true,result:<service value>}` or `{ok:false,error:<code>}`. History returns one page; follow `seq` with `after` to fetch more.
|
|
70
112
|
|
|
71
113
|
`start_thread`, `list-members`, and `remove-member` are dedicated runtime commands rather than shared management routes. `list-members` retains its contact-safe roster. `remove-member` retains its epoch, confirm and no-self-removal gates. The separate `room.participant.remove` command instead grants the full operator removal behavior. `room.show` returns only public room settings and mission content; `room.participants` returns participant IDs, roles, and states. Runtime `room.history` returns only messages visible to the authenticated active seat, with viewer-local cursors, and rejects operator view. Runtime `room.message` and `room.say` return only an accepted message-ID receipt. Host management routes retain their full operator results. Policy-administration commands can delegate more privileges; `room.message` and `room.say` authorize room/role authorship. Assign these permissions deliberately. Command results may include invite material; do not relay them into chat.
|
|
72
114
|
|
|
73
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.
|
|
74
116
|
|
|
75
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.
|