@awak-app/simy-cli 0.7.16 → 0.7.18

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 (80) hide show
  1. package/README.md +180 -26
  2. package/package.json +3 -2
  3. package/src/agent.js +128 -7
  4. package/src/bounded-local-task-subtask-pool.js +32 -6
  5. package/src/bounded-local-task-subtasks-contract.js +1 -1
  6. package/src/cli-contract.js +1 -1
  7. package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
  8. package/src/codex-plugins/simy/hooks/README.md +8 -3
  9. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
  10. package/src/codex-plugins/simy/hooks/hooks.json +1 -1
  11. package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
  12. package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
  13. package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
  14. package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
  15. package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
  16. package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
  17. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
  18. package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
  19. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
  20. package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
  21. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
  22. package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
  23. package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
  24. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
  25. package/src/coding-run-recovery.js +4 -2
  26. package/src/delivery-hooks/claude-workflow.md +10 -7
  27. package/src/delivery-hooks/command.js +45 -10
  28. package/src/delivery-hooks/consult.js +60 -0
  29. package/src/delivery-hooks/diagnosis.js +2 -2
  30. package/src/delivery-hooks/legacy-hook-routing.js +46 -8
  31. package/src/delivery-hooks/local-runtime.js +50 -1
  32. package/src/delivery-hooks/local-supervisor.js +1 -1
  33. package/src/delivery-hooks/repair-continuation.js +80 -0
  34. package/src/delivery-hooks/repair.js +164 -0
  35. package/src/delivery-hooks/supervisor-service.js +3 -1
  36. package/src/delivery-hooks/supervisor.js +88 -12
  37. package/src/desktop-access.js +2 -1
  38. package/src/desktop-host.js +62 -2
  39. package/src/durable-local-task-worker.js +372 -58
  40. package/src/execution-capability-contract.js +15 -1
  41. package/src/index.js +3 -3
  42. package/src/local-photos-read.swift +33 -0
  43. package/src/local-photos.js +47 -0
  44. package/src/local-task-attachment-store.js +31 -0
  45. package/src/local-task-destination.js +174 -0
  46. package/src/local-task.js +119 -7
  47. package/src/native-delegation-contract.js +68 -0
  48. package/src/native-provider-identity.js +60 -0
  49. package/src/orchestrator/budget.js +22 -11
  50. package/src/orchestrator/independent-audit.js +9 -18
  51. package/src/orchestrator/loop.js +27 -59
  52. package/src/orchestrator/recovery.js +32 -2
  53. package/src/provider-delegation.js +336 -0
  54. package/src/runner.js +2 -1
  55. package/src/session-supervision/artifact-observer.js +138 -0
  56. package/src/session-supervision/claude-sessions.js +5 -3
  57. package/src/session-supervision/codex-app-server.js +20 -5
  58. package/src/session-supervision/completion-artifacts.js +44 -0
  59. package/src/session-supervision/consistency-auditor.js +8 -11
  60. package/src/session-supervision/continuation-analysis.js +46 -28
  61. package/src/session-supervision/discovery-window.js +1 -1
  62. package/src/session-supervision/final-response-recovery.js +5 -3
  63. package/src/session-supervision/index.js +25 -8
  64. package/src/session-supervision/purpose-recovery.js +12 -3
  65. package/src/session-supervision/purpose-verification.js +9 -57
  66. package/src/session-supervision/recovery-frontier.js +55 -0
  67. package/src/session-supervision/remote-hosts.js +1 -1
  68. package/src/session-supervision/stop-notifications.js +8 -8
  69. package/src/sqm/agent-skill.js +1 -1
  70. package/src/sqm/checkers.js +12 -6
  71. package/src/sqm/claude-plugin-update.js +22 -8
  72. package/src/sqm/claude-plugin.js +4 -0
  73. package/src/sqm/local-tests.js +2 -2
  74. package/src/sqm/plugin-cache.js +67 -0
  75. package/src/sqm/proof.js +25 -0
  76. package/src/sqm/report.js +1 -0
  77. package/src/sqm/workflow-onboarding.js +3 -39
  78. package/src/sqm/workflow-update.js +17 -6
  79. package/src/team-status-fence-store.js +32 -0
  80. package/src/team-threads.js +478 -0
@@ -7,6 +7,7 @@ import { pinnedWorkflowSource } from "../delivery-hooks/local-runtime.js";
7
7
 
8
8
  import { runProviderCommand } from "./provider-command.js";
9
9
  import { refreshClaudePlugin } from "./claude-plugin-update.js";
10
+ import { pluginCacheMatches } from "./plugin-cache.js";
10
11
 
11
12
  import { resolveBackendExecutable } from "../backend-executable.js";
12
13
  import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from "./agent-skill.js";
@@ -14,13 +15,18 @@ import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from
14
15
 
15
16
  const CHECK_INTERVAL_MS = 60 * 60 * 1000;
16
17
 
17
- export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
18
+ export function startWorkflowUpdates({ enabled, installMissing = true, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
18
19
  errorLogger = console.error } = {}) {
19
20
  let pending;
20
21
  let stopped = !enabled;
21
22
  const check = () => {
22
23
  if (stopped || pending) return;
23
- pending = Promise.resolve().then(sync).catch((error) => {
24
+ pending = Promise.resolve().then(() => sync({ installMissing })).then((result) => {
25
+ if (result?.refreshPending) {
26
+ const details = result.refreshFailures?.map(({ provider, message }) => `${provider}: ${message}`).join('; ');
27
+ errorLogger(`SIMY workflow sources are prepared, but plugin cache refresh is pending; will retry later.${details ? ` ${details}` : ''}`);
28
+ }
29
+ }).catch((error) => {
24
30
  errorLogger(`SIMY workflow update failed; will retry later: ${error.message}`);
25
31
  }).finally(() => { pending = null; });
26
32
  };
@@ -79,16 +85,20 @@ export async function syncInstalledWorkflows({ home = homedir(), installMissing
79
85
  if (platforms.length) status = await install({ home, platforms });
80
86
  const results = {};
81
87
  const failures = [];
88
+ const refreshFailures = [];
82
89
  for (const [provider, refresh] of [
83
90
  ["codex", () => refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run })],
84
91
  ["claude", () => refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run })],
85
92
  ]) {
86
93
  try { results[provider] = await refresh(); }
87
- catch (error) { failures.push(error); }
94
+ catch (error) {
95
+ failures.push(error);
96
+ refreshFailures.push({ provider, message: error.message });
97
+ }
88
98
  }
89
99
  // Explicit desktop setup prepares both sources even when a provider CLI is
90
100
  // absent. Cache refresh remains retryable; no provider is enabled here.
91
- if (failures.length && installMissing) return { outcome: "prepared", refreshPending: true };
101
+ if (failures.length && installMissing) return { outcome: "prepared", refreshPending: true, refreshFailures, providers: results };
92
102
  if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("; "));
93
103
  if (results.claude.outcome === "updated") return { ...results.claude, providers: results };
94
104
  if (results.codex.outcome !== "not_enabled") return results.codex;
@@ -123,8 +133,9 @@ async function refreshCodexPlugin({ status, home, codexHome, resolveExecutable,
123
133
  const cacheManifest = path.join(codexHome, "plugins", "cache", marketplace.name,
124
134
  "simy", status.codex_plugin.version, ".codex-plugin", "plugin.json");
125
135
  const cacheCurrent = async () => {
126
- try { return JSON.parse(await readFile(cacheManifest, "utf8")).version === status.codex_plugin.version; }
127
- catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return false; throw error; }
136
+ try { return JSON.parse(await readFile(cacheManifest, "utf8")).version === status.codex_plugin.version &&
137
+ await pluginCacheMatches(status.codex_plugin.path, path.dirname(path.dirname(cacheManifest))); }
138
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code) || error instanceof SyntaxError) return false; throw error; }
128
139
  };
129
140
  if (installed.version === status.codex_plugin.version && await cacheCurrent()) return { outcome: "current" };
130
141
  await run(executable, ["plugin", "add", selector, "--json"], options);
@@ -0,0 +1,32 @@
1
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ // Only an opaque device/account/org/thread digest and a pending OFF version
5
+ // persist. Conversation text and provider identifiers never enter this store.
6
+ export function createTeamStatusFenceStore(root) {
7
+ const directory = join(root, 'team-status-revocations');
8
+ const path = key => {
9
+ if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid revocation scope');
10
+ return join(directory, `${key}.json`);
11
+ };
12
+ return {
13
+ async read(key) {
14
+ let raw;
15
+ try { raw = await readFile(path(key), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
16
+ const value = JSON.parse(raw);
17
+ if (value?.schemaVersion !== 1 || !Number.isSafeInteger(value.expectedVersion) || value.expectedVersion < 0) throw new Error('Invalid revocation fence');
18
+ return { expectedVersion: value.expectedVersion };
19
+ },
20
+ async write(key, expectedVersion) {
21
+ await mkdir(directory, { recursive: true, mode: 0o700 });
22
+ const destination = path(key), temporary = `${destination}.${randomUUID()}.tmp`;
23
+ try {
24
+ const handle = await open(temporary, 'wx', 0o600);
25
+ try { await handle.writeFile(JSON.stringify({schemaVersion:1,expectedVersion})); await handle.sync(); } finally { await handle.close(); }
26
+ await rename(temporary, destination);
27
+ if (process.platform !== 'win32') { const parent = await open(directory, 'r'); try { await parent.sync(); } finally { await parent.close(); } }
28
+ } finally { await rm(temporary, {force:true}); }
29
+ },
30
+ async clear(key) { await rm(path(key), {force:true}); },
31
+ };
32
+ }
@@ -0,0 +1,478 @@
1
+ function profileAvatar(value) {
2
+ if (typeof value !== "string" || value.length > 2048) return {};
3
+ try {
4
+ const url = new URL(value);
5
+ if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return {};
6
+ return { avatarUrl: url.toString() };
7
+ } catch { return {}; }
8
+ }
9
+ import { createHash } from "node:crypto";
10
+ import { sanitizedText } from "./session-supervision/conversation-context.js";
11
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
12
+ const keyPattern = /^[a-f0-9]{64}$/;
13
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ const empty = () => ({
15
+ state: "loading",
16
+ members: [],
17
+ threads: [],
18
+ own: [],
19
+ detail: null,
20
+ pendingStops: [],
21
+ memberId: null,
22
+ threadsTruncated: false,
23
+ });
24
+ const invalid = () => {
25
+ throw new Error("Shared conversations unavailable");
26
+ };
27
+ export const teamThreadKey = (scope, candidate) =>
28
+ digest(
29
+ JSON.stringify([scope.fenceKey, candidate.provider, candidate.session]),
30
+ );
31
+ function projectSummary(row) {
32
+ if (
33
+ !uuid.test(row?.memberId) ||
34
+ !keyPattern.test(row?.threadKey) ||
35
+ (row.parentThreadKey != null && (!keyPattern.test(row.parentThreadKey) || row.parentThreadKey === row.threadKey)) ||
36
+ typeof row.title !== "string" ||
37
+ !row.title.trim() ||
38
+ row.title.length > 240 ||
39
+ !["running", "needs_attention", "unknown"].includes(row.state) ||
40
+ !Number.isFinite(Date.parse(row.observedAt))
41
+ )
42
+ invalid();
43
+ return {
44
+ memberId: row.memberId,
45
+ threadKey: row.threadKey,
46
+ parentThreadKey: row.parentThreadKey ?? null,
47
+ title: sanitizedText(row.title),
48
+ state: row.state,
49
+ observedAt: row.observedAt,
50
+ };
51
+ }
52
+ export function projectThreadContext(result, org) {
53
+ if (
54
+ result?.schemaVersion !== 1 ||
55
+ result.organizationId !== org ||
56
+ !Array.isArray(result.members) ||
57
+ result.members.length > 2000 ||
58
+ !Array.isArray(result.threads) ||
59
+ result.threads.length > 200 ||
60
+ !Array.isArray(result.own) ||
61
+ result.own.length > 200
62
+ )
63
+ invalid();
64
+ const members = result.members.map((row) => {
65
+ if (
66
+ !uuid.test(row?.id) ||
67
+ typeof row.label !== "string" ||
68
+ !row.label.trim() ||
69
+ row.label.length > 240
70
+ )
71
+ invalid();
72
+ return { id: row.id, label: sanitizedText(row.label), ...profileAvatar(row.avatarUrl) };
73
+ });
74
+ const own = result.own.map((row) => {
75
+ if (
76
+ !keyPattern.test(row?.threadKey) ||
77
+ !Number.isSafeInteger(row.version) ||
78
+ row.version < 0 ||
79
+ typeof row.enabled !== "boolean"
80
+ )
81
+ invalid();
82
+ return {
83
+ threadKey: row.threadKey,
84
+ version: row.version,
85
+ enabled: row.enabled,
86
+ };
87
+ });
88
+ const threads = result.threads.map(projectSummary);
89
+ if (
90
+ new Set(members.map((x) => x.id)).size !== members.length ||
91
+ new Set(own.map((x) => x.threadKey)).size !== own.length
92
+ )
93
+ invalid();
94
+ return { members, own, threads };
95
+ }
96
+ export function createTeamThreads({
97
+ readScope,
98
+ request,
99
+ fenceStore,
100
+ readContext = async (...args) => (await import("./session-supervision/continuation-analysis.js")).readContinuationContext(...args),
101
+ onChange = () => {},
102
+ now = Date.now,
103
+ pollMs = 30000,
104
+ }) {
105
+ let value = empty(),
106
+ scopeKey,
107
+ generation = 0,
108
+ controller,
109
+ busy = false,
110
+ closed = false,
111
+ lastPoll = -Infinity,
112
+ candidates = [],
113
+ selected = null,
114
+ memberFilter = null,
115
+ pending = new Map(),
116
+ publicationGrants = new Map(),
117
+ storageFailed = false,
118
+ lastAllowed = false;
119
+ function scope() {
120
+ const current = readScope();
121
+ if (scopeKey !== current.key || (lastAllowed && !current.allowed)) {
122
+ controller?.abort();
123
+ generation++;
124
+ const sameIdentity = scopeKey === current.key;
125
+ scopeKey = current.key;
126
+ const own = sameIdentity && current.authenticated ? value.own : [];
127
+ value = { ...empty(), own };
128
+ selected = null;
129
+ memberFilter = null;
130
+ if (!sameIdentity) pending = new Map();
131
+ publicationGrants.clear();
132
+ lastPoll = -Infinity;
133
+ }
134
+ lastAllowed = current.allowed;
135
+ if (!current.allowed) value.state = "unavailable";
136
+ return current;
137
+ }
138
+ const snapshot = () => {
139
+ scope();
140
+ return structuredClone({ ...value, pendingStops: [...pending.keys()], confirmationRequired: value.own.filter(row => row.enabled && publicationGrants.get(row.threadKey) !== row.version).map(row => row.threadKey) });
141
+ };
142
+ const fenceKey = (current, key) => digest(`${current.fenceKey}:${key}`);
143
+ async function run(fn, allowOff = false) {
144
+ const current = scope();
145
+ if (
146
+ busy ||
147
+ closed ||
148
+ !current.authenticated ||
149
+ (!current.allowed && !allowOff)
150
+ )
151
+ return false;
152
+ busy = true;
153
+ controller = new AbortController();
154
+ const own = controller,
155
+ epoch = generation;
156
+ const valid = () =>
157
+ !closed &&
158
+ generation === epoch &&
159
+ readScope().key === current.key &&
160
+ readScope().authenticated &&
161
+ (readScope().allowed || allowOff) &&
162
+ !own.signal.aborted;
163
+ const send = async (body) => {
164
+ if (!valid()) invalid();
165
+ const result = await request(body, own.signal);
166
+ if (
167
+ !valid() ||
168
+ result?.schemaVersion !== 1 ||
169
+ result.organizationId !== current.organizationId
170
+ )
171
+ invalid();
172
+ return result;
173
+ };
174
+ try {
175
+ await fn({ current, send, valid, signal: own.signal });
176
+ return valid();
177
+ } catch {
178
+ if (valid()) {
179
+ value = {
180
+ ...empty(),
181
+ own: value.own,
182
+ state: storageFailed ? "local_storage" : "unavailable",
183
+ };
184
+ onChange();
185
+ }
186
+ return false;
187
+ } finally {
188
+ busy = false;
189
+ }
190
+ }
191
+ async function context(send) {
192
+ const raw = await send({
193
+ operation: "context",
194
+ ...(memberFilter ? { memberId: memberFilter } : {}),
195
+ });
196
+ const result = projectThreadContext(raw, readScope().organizationId);
197
+ if (
198
+ (raw.memberId ?? null) !== memberFilter ||
199
+ typeof raw.threadsTruncated !== "boolean"
200
+ )
201
+ invalid();
202
+ value = {
203
+ ...value,
204
+ ...result,
205
+ memberId: memberFilter,
206
+ threadsTruncated: raw.threadsTruncated,
207
+ state: "ready",
208
+ };
209
+ if (selected && !raw.threadsTruncated && !value.threads.some(row => row.memberId === selected.memberId && row.threadKey === selected.threadKey)) {
210
+ selected = null;
211
+ value.detail = null;
212
+ }
213
+ }
214
+ async function clearStop(current, key) {
215
+ await fenceStore.clear(fenceKey(current, key));
216
+ pending.delete(key);
217
+ }
218
+ async function stop(send, current, key, version) {
219
+ const result = await send({
220
+ operation: "consent",
221
+ threadKey: key,
222
+ expectedVersion: version,
223
+ enabled: false,
224
+ });
225
+ if (
226
+ result.threadKey !== key ||
227
+ result.enabled !== false ||
228
+ !Number.isSafeInteger(result.version) ||
229
+ result.version <= version
230
+ )
231
+ invalid();
232
+ await clearStop(current, key);
233
+ value.own = value.own.map((row) =>
234
+ row.threadKey === key
235
+ ? { ...row, enabled: false, version: result.version }
236
+ : row,
237
+ );
238
+ }
239
+ async function readSelected(send) {
240
+ if (!selected) return;
241
+ const selection = { ...selected };
242
+ const result = await send({ operation: "read", ...selection });
243
+ if (
244
+ selected?.memberId !== selection.memberId ||
245
+ selected?.threadKey !== selection.threadKey
246
+ )
247
+ return;
248
+ const summary = projectSummary(result);
249
+ if (
250
+ summary.memberId !== selection.memberId ||
251
+ summary.threadKey !== selection.threadKey ||
252
+ !Array.isArray(result.messages) ||
253
+ result.messages.length > 32 ||
254
+ typeof result.truncated !== "boolean" ||
255
+ typeof result.incomplete !== "boolean"
256
+ )
257
+ invalid();
258
+ let total = 0;
259
+ const messages = result.messages.map((row) => {
260
+ if (
261
+ !["user", "assistant"].includes(row?.role) ||
262
+ typeof row.text !== "string" ||
263
+ row.text.length > 4000
264
+ )
265
+ invalid();
266
+ total += row.text.length;
267
+ return { role: row.role, text: sanitizedText(row.text) };
268
+ });
269
+ if (total > 24000) invalid();
270
+ value.detail = {
271
+ ...summary,
272
+ messages,
273
+ truncated: result.truncated,
274
+ incomplete: result.incomplete,
275
+ };
276
+ }
277
+ function sourceObservedAt(candidate) {
278
+ const observed = candidate.observation?.observedAt;
279
+ if (!Number.isFinite(observed) || observed <= 0 || observed > now()) invalid();
280
+ return new Date(observed).toISOString();
281
+ }
282
+ async function payload(candidate, signal) {
283
+ const result = await readContext(candidate, { signal });
284
+ if (!Array.isArray(result?.messages) || !result.messages.length) invalid();
285
+ // Existing provider readers exclude attachments/tool payloads and bound prose.
286
+ const messages = result.messages.map(({ role, text }) => ({ role, text }));
287
+ const observation = candidate.observation;
288
+ const running =
289
+ candidate.isRunning === true &&
290
+ observation?.source === "codex_app_server" &&
291
+ Number.isFinite(observation.observedAt) &&
292
+ now() - observation.observedAt >= 0 &&
293
+ now() - observation.observedAt <= 300000;
294
+ const parent = candidate.parentSession;
295
+ const parentThreadKey = parent && typeof parent.provider === "string" && typeof parent.session === "string" && parent.session
296
+ && !(parent.provider === candidate.provider && parent.session === candidate.session)
297
+ ? teamThreadKey(readScope(), parent) : null;
298
+ return {
299
+ parentThreadKey,
300
+ title: sanitizedText(candidate.title || "Work conversation").slice(
301
+ 0,
302
+ 240,
303
+ ),
304
+ state: running ? "running" : "unknown",
305
+ observedAt: sourceObservedAt(candidate),
306
+ messages,
307
+ truncated: result.truncated === true,
308
+ incomplete: result.incomplete === true,
309
+ };
310
+ }
311
+ async function refresh({ publish = false, memberId = undefined } = {}) {
312
+ if (memberId !== undefined && memberFilter !== (memberId || null)) {
313
+ scope();
314
+ controller?.abort();
315
+ generation++;
316
+ memberFilter = memberId || null;
317
+ selected = null;
318
+ value.threads = [];
319
+ value.detail = null;
320
+ value.memberId = memberFilter;
321
+ onChange();
322
+ while (busy && !closed)
323
+ await new Promise((resolve) => setTimeout(resolve, 10));
324
+ }
325
+ return run(async ({ current, send, valid, signal }) => {
326
+ lastPoll = now();
327
+ await context(send);
328
+ for (const item of value.own) {
329
+ const fence = await fenceStore.read(fenceKey(current, item.threadKey));
330
+ if (!valid()) return;
331
+ if (fence) {
332
+ // A lost OFF response (or intervening version) must reconcile against
333
+ // authenticated current state, rather than retry a stale CAS forever.
334
+ pending.set(item.threadKey, item.version);
335
+ if (item.enabled) await stop(send, current, item.threadKey, item.version);
336
+ else await clearStop(current, item.threadKey);
337
+ }
338
+ }
339
+ if (publish && !storageFailed)
340
+ for (const item of [...value.own]) {
341
+ // Server consent alone cannot re-arm publication after a process restart:
342
+ // an OFF may have failed both durable storage and the network.
343
+ if (!item.enabled || pending.has(item.threadKey) || publicationGrants.get(item.threadKey) !== item.version) continue;
344
+ const candidate = candidates.find(
345
+ (c) => teamThreadKey(current, c) === item.threadKey,
346
+ );
347
+ if (!candidate) continue;
348
+ let body;
349
+ try {
350
+ body = await payload(candidate, signal);
351
+ } catch {
352
+ continue;
353
+ }
354
+ if (!valid() || pending.has(item.threadKey)) return;
355
+ const receipt = await send({
356
+ operation: "publish",
357
+ threadKey: item.threadKey,
358
+ expectedVersion: item.version,
359
+ ...body,
360
+ });
361
+ if (
362
+ receipt.threadKey !== item.threadKey ||
363
+ receipt.enabled !== true ||
364
+ receipt.version !== item.version
365
+ )
366
+ invalid();
367
+ }
368
+ await readSelected(send);
369
+ if (valid()) onChange();
370
+ });
371
+ }
372
+ return {
373
+ snapshot,
374
+ tick(sessions) {
375
+ candidates = sessions;
376
+ scope();
377
+ if (!busy && !closed && now() - lastPoll >= pollMs) {
378
+ lastPoll = now();
379
+ void refresh({ publish: true });
380
+ }
381
+ },
382
+ refresh,
383
+ async open({ memberId, threadKey }) {
384
+ scope();
385
+ selected = memberId && threadKey ? { memberId, threadKey } : null;
386
+ value.detail = null;
387
+ onChange();
388
+ if (!selected) return true;
389
+ // An in-flight context/publish must not replace this newer selection.
390
+ controller?.abort();
391
+ generation++;
392
+ while (busy && !closed)
393
+ await new Promise((resolve) => setTimeout(resolve, 10));
394
+ return run(async ({ send, valid }) => {
395
+ await readSelected(send);
396
+ if (valid()) onChange();
397
+ });
398
+ },
399
+ async stopSharing(threadKey) {
400
+ scope();
401
+ if (!keyPattern.test(threadKey) || !value.own.some(row => row.threadKey === threadKey)) return false;
402
+ return setSharing(null, false, threadKey);
403
+ },
404
+ setSharing,
405
+ close() {
406
+ closed = true;
407
+ controller?.abort();
408
+ generation++;
409
+ publicationGrants.clear();
410
+ value = empty();
411
+ },
412
+ };
413
+ async function setSharing(candidate, enabled, existingKey) {
414
+ const current = scope(),
415
+ key = existingKey ?? teamThreadKey(current, candidate),
416
+ item = value.own.find((row) => row.threadKey === key),
417
+ version = item?.version ?? 0;
418
+ if (
419
+ typeof enabled !== "boolean" ||
420
+ (enabled && (!current.allowed || busy || pending.has(key)))
421
+ )
422
+ return false;
423
+ if (enabled && storageFailed) {
424
+ // An OFF acknowledgement confirms server removal, not writable local
425
+ // storage. Revalidate durability before accepting a later explicit ON.
426
+ if (item?.enabled !== false) return false;
427
+ // Probe a distinct opaque key so a concurrent OFF cannot lose its fence.
428
+ const probe = digest(`storage-probe:${current.fenceKey}`);
429
+ try {
430
+ await fenceStore.write(probe, 0);
431
+ await fenceStore.clear(probe);
432
+ } catch { return false; }
433
+ if (readScope().key !== current.key || pending.has(key)) return false;
434
+ storageFailed = false;
435
+ }
436
+ if (!enabled) {
437
+ publicationGrants.delete(key);
438
+ pending.set(key, version);
439
+ controller?.abort();
440
+ generation++;
441
+ onChange();
442
+ try {
443
+ await fenceStore.write(fenceKey(current, key), version);
444
+ } catch {
445
+ storageFailed = true;
446
+ value.state = "local_storage";
447
+ onChange();
448
+ }
449
+ }
450
+ while (busy && !closed)
451
+ await new Promise((resolve) => setTimeout(resolve, 10));
452
+ if (readScope().key !== current.key || !readScope().authenticated) return false;
453
+ return run(async ({ current, send, valid, signal }) => {
454
+ if (!enabled) await stop(send, current, key, version);
455
+ else {
456
+ const fence = await fenceStore.read(fenceKey(current, key));
457
+ if (fence) invalid();
458
+ const body = await payload(candidate, signal);
459
+ const receipt = await send({
460
+ operation: "consent",
461
+ threadKey: key,
462
+ expectedVersion: version,
463
+ enabled: true,
464
+ ...body,
465
+ });
466
+ if (
467
+ receipt.threadKey !== key ||
468
+ receipt.enabled !== true ||
469
+ receipt.version !== version + 1
470
+ )
471
+ invalid();
472
+ if (valid() && !pending.has(key)) publicationGrants.set(key, receipt.version);
473
+ }
474
+ if (current.allowed) await context(send);
475
+ if (valid()) onChange();
476
+ }, !enabled);
477
+ }
478
+ }