@awak-app/simy-cli 0.5.45 → 0.5.46

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
@@ -11,6 +11,22 @@ login status, tasks, and updates separately. A Codex login check does not prove
11
11
  that a task can execute successfully; permissions and service availability are
12
12
  checked when work starts.
13
13
 
14
+ The sidebar's **Organization** selector is global across Sessions, Tasks and SQM
15
+ and shows active memberships of the connected SIMY account. Switching opens browser authorization for the selected organization
16
+ and waits for active tasks to finish. Use the same account in the browser; cancel
17
+ an unfinished switch from the sidebar.
18
+
19
+ **Local SQM**, above the connection status, allows this Mac's Codex / Claude Code
20
+ to claim local SQM reviews. It defaults to off and is saved separately for each
21
+ Web environment, account, and organization. Turning it off stops new claims;
22
+ current analysis finishes and submits its result. The toggle does not grant SQM
23
+ subscription or organization permissions. First-time repository history discovery
24
+ still starts from the web workspace.
25
+
26
+ **SQM Tasks** shows local SQM reviews from the current agent session, separately
27
+ from ordinary **Tasks**. Up to 50 recent reviews are shown; changing organization
28
+ clears the local view. The web SQM workspace retains the authoritative history.
29
+
14
30
  Closing the window keeps the agent running. Open the app again or choose
15
31
  **Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
16
32
  interrupting active tasks. The normal `simy` command remains available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.45",
3
+ "version": "0.5.46",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
1
2
  import { createServer } from "node:http";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import { createReadStream } from "node:fs";
@@ -162,6 +163,7 @@ export async function startAgent({
162
163
  dependencies = {},
163
164
  quiet = false,
164
165
  } = {}) {
166
+ sessionRoot ??= defaultSessionRoot();
165
167
  const apiOrigin = resolveWebOrigin(webOrigin);
166
168
  const registry = new LocalRunRegistry();
167
169
  const codingRunStateRoot = codingRunsRoot(sessionRoot);
@@ -178,7 +180,7 @@ export async function startAgent({
178
180
  join(dirname(taskStorageRoot), "attachment-staging"),
179
181
  });
180
182
  await localTaskAttachmentStore.cleanupExpired?.();
181
- const authNonce = randomBytes(16).toString("base64url");
183
+ let authNonce = randomBytes(16).toString("base64url");
182
184
  const sqmAgentIntegration = dependencies.sqmAgentIntegration || {
183
185
  inspect: () => inspectSqmAgentSkill({ home: dependencies.home }),
184
186
  install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
@@ -188,6 +190,11 @@ export async function startAgent({
188
190
  // as its device (see /v1/auth/complete), so keep it after it is cleared.
189
191
  let reclaimableSession = null;
190
192
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
193
+ let sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
194
+ let organizationItems = [];
195
+ let organizationState = "unknown";
196
+ let organizationRefresh = null;
197
+ let pendingOrganization = null;
191
198
  await cleanupExpiredRuns();
192
199
  for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
193
200
  localTaskRegistry.create(task);
@@ -217,9 +224,14 @@ export async function startAgent({
217
224
  let availableCapabilities;
218
225
  let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
219
226
  const refreshAvailableCapabilities = () => {
220
- availableCapabilities = withCliContract({ ...contractSourceCapabilities, ...(providerStatuses ? { provider_statuses: providerStatuses } : {}) }, installMode, {
221
- authenticated:
222
- isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
227
+ const features = contractSourceCapabilities.features;
228
+ availableCapabilities = withCliContract({
229
+ ...contractSourceCapabilities,
230
+ ...(features ? { features: { ...features, ...(Object.hasOwn(features, "sqm_local_review")
231
+ ? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization } : {}) } } : {}),
232
+ ...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
233
+ }, installMode, {
234
+ authenticated: isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
223
235
  });
224
236
  return availableCapabilities;
225
237
  };
@@ -382,8 +394,43 @@ export async function startAgent({
382
394
  }
383
395
  };
384
396
 
385
- const requestDesktopLogin = () => {
386
- if (isSessionValid(session, Date.now(), apiOrigin)) return null;
397
+ const organizationBusy = () => Boolean(sqmLocalReviewWorker?.snapshot?.().polling ||
398
+ durableLocalTaskWorker?.snapshot?.().polling || durableLocalTaskWorker?.snapshot?.().active_turn_id ||
399
+ desktopTaskStatus([registry, directRegistry, localTaskRegistry]).activeCount);
400
+ const refreshOrganizations = async () => {
401
+ if (!isSessionValid(session, Date.now(), apiOrigin)) return;
402
+ if (organizationRefresh) return organizationRefresh;
403
+ const requestedSession = session;
404
+ organizationState = "loading";
405
+ organizationRefresh = (async () => {
406
+ try {
407
+ const response = await (dependencies.fetch || globalThis.fetch)(webApiUrl("organizations", {
408
+ webOrigin: apiOrigin, apiBaseUrl: requestedSession.api_base_url,
409
+ }), { headers: webApiHeaders({ token: requestedSession.token }), signal: AbortSignal.timeout(10_000) });
410
+ if (!response.ok) throw new Error("Organization lookup unavailable.");
411
+ const payload = await response.json();
412
+ if (!Array.isArray(payload.organizations)) throw new Error("Invalid organization list.");
413
+ if (session !== requestedSession) return;
414
+ organizationItems = payload.organizations.filter(org => typeof org.id === "string" && typeof org.name === "string")
415
+ .slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120) }));
416
+ organizationState = "ready";
417
+ } catch {
418
+ if (session === requestedSession) organizationState = "unavailable";
419
+ } finally { organizationRefresh = null; notifyDesktopStatusChange(); }
420
+ })();
421
+ return organizationRefresh;
422
+ };
423
+ const requestDesktopLogin = (organizationId = null) => {
424
+ if (organizationId) {
425
+ if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
426
+ if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
427
+ if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
428
+ authNonce = randomBytes(16).toString("base64url");
429
+ pendingOrganization = { id: organizationId, accountId: session.account_id };
430
+ loginUrl = null;
431
+ refreshAvailableCapabilities();
432
+ notifyDesktopStatusChange();
433
+ } else if (isSessionValid(session, Date.now(), apiOrigin)) return null;
387
434
  if (!loginUrl) {
388
435
  loginUrl = new URL("/local-cli/connect", apiOrigin);
389
436
  loginUrl.searchParams.set("port", String(port));
@@ -391,6 +438,7 @@ export async function startAgent({
391
438
  loginUrl.searchParams.set("cli_version", CLI_VERSION);
392
439
  loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
393
440
  if (installMode) loginUrl.searchParams.set("install_mode", installMode);
441
+ if (organizationId) loginUrl.searchParams.set("org_id", organizationId);
394
442
  }
395
443
  return loginUrl.toString();
396
444
  };
@@ -561,8 +609,8 @@ export async function startAgent({
561
609
  } else {
562
610
  await publishVerifiedProviders();
563
611
  }
564
- if (shuttingDown) return false;
565
- const sqmResult = sqmLocalReviewWorker
612
+ if (shuttingDown || pendingOrganization) return false;
613
+ const sqmResult = sqmEnabled && sqmLocalReviewWorker
566
614
  ? await sqmLocalReviewWorker.poll()
567
615
  : null;
568
616
  if (shuttingDown) return false;
@@ -715,7 +763,8 @@ export async function startAgent({
715
763
  api: sqmApi,
716
764
  beginWork: () => acquireUpdateWork(updateManager),
717
765
  runReview: dependencies.runSqmLocalReview,
718
- getProvider: () => ["codex", "claude"].find(
766
+ onChange: notifyDesktopStatusChange,
767
+ getProvider: () => availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
719
768
  (provider) => availableCapabilities?.backends?.[provider] === true &&
720
769
  providerStatuses[provider]?.state === "ready",
721
770
  ) || null,
@@ -996,6 +1045,14 @@ export async function startAgent({
996
1045
  });
997
1046
  return;
998
1047
  }
1048
+ const nextOrganization = body.organization_id ?? body.org_id;
1049
+ const nextAccount = body.account_id ?? body.auth_user_id;
1050
+ const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
1051
+ if ((changingScope && organizationBusy()) || (pendingOrganization &&
1052
+ (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
1053
+ json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
1054
+ return;
1055
+ }
999
1056
  const previousSession = session ?? reclaimableSession;
1000
1057
  reclaimableSession = null;
1001
1058
  session = {
@@ -1024,6 +1081,8 @@ export async function startAgent({
1024
1081
  session.device_id &&
1025
1082
  previousSession.device_id !== session.device_id &&
1026
1083
  previousSession.api_origin &&
1084
+ (previousSession.account_id ?? null) === (session.account_id ?? null) &&
1085
+ (previousSession.organization_id ?? null) === (session.organization_id ?? null) &&
1027
1086
  previousApiBaseUrl(previousSession) === session.api_base_url
1028
1087
  ) {
1029
1088
  try {
@@ -1047,6 +1106,14 @@ export async function startAgent({
1047
1106
  }
1048
1107
  }
1049
1108
  await writeSession(apiOrigin, session, sessionRoot);
1109
+ authNonce = randomBytes(16).toString("base64url");
1110
+ pendingOrganization = null;
1111
+ sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1112
+ if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1113
+ organizationState = "unknown";
1114
+ organizationItems = [];
1115
+ void refreshOrganizations();
1116
+ refreshAvailableCapabilities();
1050
1117
  startHeartbeatTimer();
1051
1118
  loginUrl = null;
1052
1119
  registry.emit("change", registry.list());
@@ -2061,6 +2128,7 @@ export async function startAgent({
2061
2128
  });
2062
2129
  const getDesktopStatus = async () => {
2063
2130
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
2131
+ if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
2064
2132
  if (deferStartupSynchronization && !shuttingDown) {
2065
2133
  // Cached checks return immediately; expired checks refresh in the
2066
2134
  // background without making the desktop frame wait for subprocesses.
@@ -2077,6 +2145,13 @@ export async function startAgent({
2077
2145
  auth,
2078
2146
  connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
2079
2147
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
2148
+ organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
2149
+ switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
2150
+ sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization,
2151
+ activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2152
+ issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2153
+ providerReady: [codex, claude].some(provider => provider?.state === "ready") },
2154
+ sqmTasks: sqmLocalReviewWorker?.desktopTasks?.() ?? { activeCount: 0, items: [] },
2080
2155
  update: updateManager?.snapshot?.() ?? null,
2081
2156
  codex,
2082
2157
  claude,
@@ -2098,8 +2173,20 @@ export async function startAgent({
2098
2173
  return requestDesktopLogin();
2099
2174
  },
2100
2175
  requestDesktopLogin,
2176
+ refreshOrganizations,
2177
+ cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
2178
+ async setSqmEnabled(enabled) {
2179
+ if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
2180
+ const settingsSession = session;
2181
+ await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
2182
+ if (session !== settingsSession) return;
2183
+ sqmEnabled = enabled;
2184
+ refreshAvailableCapabilities();
2185
+ notifyDesktopStatusChange();
2186
+ void heartbeat().catch(error => reportHeartbeatError(error, quiet));
2187
+ },
2101
2188
  getDesktopStatus,
2102
- getDesktopTask: (key) => desktopTaskDetail([registry, directRegistry, localTaskRegistry], key),
2189
+ getDesktopTask: (key) => sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? desktopTaskDetail([registry, directRegistry, localTaskRegistry], key),
2103
2190
  async refreshDesktopStatus() {
2104
2191
  if (isSessionValid(session, Date.now(), apiOrigin)) {
2105
2192
  await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.45";
16
+ export const CLI_VERSION = "0.5.46";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -37,6 +37,21 @@ const silentLogger = { log() {}, error() {} };
37
37
  // exceed 128 KiB, including after a signed runtime handoff.
38
38
  const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
39
39
 
40
+ function desktopActionError(action, error) {
41
+ if (typeof action === "string" && action.startsWith("session_")) {
42
+ return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
43
+ }
44
+ if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
45
+ return error?.code === "desktop_organization_busy"
46
+ ? "Wait for running tasks to finish before switching organizations."
47
+ : "Could not switch organization. Refresh organizations and try again.";
48
+ }
49
+ if (action === "sqm_set_enabled") {
50
+ return "Could not save SQM settings. Check your SIMY connection and try again.";
51
+ }
52
+ return "Action could not finish. Refresh the status and try again; active tasks must finish before quitting.";
53
+ }
54
+
40
55
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
41
56
  dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
42
57
  const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
@@ -270,12 +285,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
270
285
  refreshSessions(true);
271
286
  void actionAssociations.tick().then(publish).catch(() => {});
272
287
  }
273
- else if (action === "login") {
274
- const url = await agent.requestDesktopLogin();
288
+ else if (action === "organizations_refresh") await agent.refreshOrganizations();
289
+ else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
290
+ else if (action === "organization_cancel") agent.cancelOrganizationSelection();
291
+ else if (action === "login" || action === "organization_select") {
292
+ if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
293
+ const url = await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
275
294
  if (url) {
276
295
  if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
277
296
  const result = await (dependencies.openLogin || openAuthorizationUrl)(url, { logger: silentLogger });
278
- if (!result.opened) throw new Error("Could not open the login page.");
297
+ if (!result.opened) { agent.cancelOrganizationSelection?.(); throw new Error("Could not open the login page."); }
279
298
  }
280
299
  } else if (action === "check_update") await updater.checkNow();
281
300
  else if (action === "update") {
@@ -376,15 +395,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
376
395
  }
377
396
  else if (action === "quit") {
378
397
  const status = await agent.getDesktopStatus();
379
- if (status.tasks.activeCount > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
398
+ if ((status.tasks.activeCount + (status.sqmTasks?.activeCount || 0)) > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
380
399
  emit({ type: "result", id: command.id, ok: true });
381
400
  await close(); exit(0); return;
382
401
  } else throw new Error("Unsupported desktop action.");
383
402
  emit({ type: "result", id: command.id, ok: true });
384
403
  await publish();
385
404
  } catch (error) {
386
- emit({ type: "result", id: command.id, ok: false, message: String(command.action || "").startsWith("session_")
387
- ? (String(command.action).startsWith("session_action_") ? associationActionError(error) : sessionActionError(error)) : "Action could not finish. Refresh the status and try again; active tasks must finish before quitting." });
405
+ emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
388
406
  }
389
407
  }).catch(() => {});
390
408
  });
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ export function sqmSettingsPath(root, origin, session) {
6
+ if (!session?.account_id || !session?.organization_id) return null;
7
+ const key = createHash('sha256').update(JSON.stringify([origin, session.account_id, session.organization_id])).digest('hex');
8
+ return join(root, 'sqm-settings', `${key}.json`);
9
+ }
10
+ export async function readSqmEnabled(root, origin, session) {
11
+ const path = sqmSettingsPath(root, origin, session);
12
+ if (!path) return false;
13
+ try { return JSON.parse(await readFile(path, 'utf8')).enabled === true; }
14
+ catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }
15
+ }
16
+ export async function writeSqmEnabled(root, origin, session, enabled) {
17
+ const path = sqmSettingsPath(root, origin, session);
18
+ if (!path || typeof enabled !== 'boolean') throw new Error('Sign in to an organization before enabling SQM.');
19
+ await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
20
+ const temporary = `${path}.${process.pid}.tmp`;
21
+ await writeFile(temporary, JSON.stringify({ enabled }), { mode: 0o600 });
22
+ await rename(temporary, path);
23
+ }
@@ -83,11 +83,19 @@ export function createSqmLocalReviewWorker({
83
83
  runReview = (request, provider) => runStructuredProviderReview(provider, request),
84
84
  beginWork = () => () => {},
85
85
  onError = () => {},
86
+ onChange = () => {},
86
87
  } = {}) {
87
88
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
88
89
  let activeJobId = null;
89
90
  let pollPromise = null;
90
91
  let closed = false;
92
+ let issue = null;
93
+ const history = [];
94
+ const updateTask = (id, state) => {
95
+ const item = history.find(item => item.id === id);
96
+ if (item) item.state = state;
97
+ onChange();
98
+ };
91
99
 
92
100
  const leaseBody = (claim, provider) => ({
93
101
  executor_provider: provider,
@@ -99,6 +107,10 @@ export function createSqmLocalReviewWorker({
99
107
 
100
108
  const processClaim = async (claim, provider) => {
101
109
  activeJobId = claim.job_id;
110
+ history.unshift({ id: claim.job_id, key: `sqm:${claim.job_id}`, label: `SQM · ${String(claim.job_id).slice(0, 8)}`,
111
+ state: "running", provider, startedAt: new Date().toISOString() });
112
+ history.splice(50);
113
+ onChange();
102
114
  let heartbeatTimer = null;
103
115
  const heartbeat = () => {
104
116
  void api.request({ action: "heartbeat", ...leaseBody(claim, provider) }).catch(onError);
@@ -113,25 +125,37 @@ export function createSqmLocalReviewWorker({
113
125
  analysis_output: analysis.output,
114
126
  executor_model: analysis.model,
115
127
  });
116
- if (advanced.completed) return advanced;
128
+ if (advanced.completed) { updateTask(claim.job_id, "completed"); return advanced; }
117
129
  const adjudication = await runReview(advanced.request, provider);
118
- return api.request({
130
+ const completed = await api.request({
119
131
  action: "complete",
120
132
  ...leaseBody(claim, provider),
121
133
  analysis_output: analysis.output,
122
134
  adjudication_output: adjudication.output,
123
135
  executor_model: adjudication.model || analysis.model,
124
136
  });
137
+ updateTask(claim.job_id, "completed");
138
+ return completed;
139
+ } catch (error) {
140
+ updateTask(claim.job_id, "failed");
141
+ throw error;
125
142
  } finally {
126
143
  if (heartbeatTimer) clearInterval(heartbeatTimer);
127
144
  activeJobId = null;
145
+ onChange();
128
146
  }
129
147
  };
130
148
 
131
149
  return {
132
150
  snapshot() {
133
- return { active_job_id: activeJobId, closed };
151
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue };
152
+ },
153
+ desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: history.map(item => ({ ...item })) }; },
154
+ desktopTaskDetail(key) {
155
+ const item = history.find(item => item.key === key);
156
+ return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.state === "failed" ? "Local review failed. Check the SQM workspace for retry status." : "", links: [] } : null;
134
157
  },
158
+ resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
135
159
  poll() {
136
160
  if (closed || pollPromise) return pollPromise || Promise.resolve(null);
137
161
  const provider = getProvider();
@@ -146,16 +170,19 @@ export function createSqmLocalReviewWorker({
146
170
  worker_id: workerId,
147
171
  lease_seconds: leaseSeconds,
148
172
  });
173
+ if (issue) { issue = null; onChange(); }
149
174
  if (!claim?.claimed) return null;
150
175
  return processClaim(claim, provider);
151
176
  })()
152
177
  .catch((error) => {
178
+ issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
153
179
  onError(error);
154
180
  return null;
155
181
  })
156
182
  .finally(() => {
157
183
  pollPromise = null;
158
184
  releaseWork();
185
+ onChange();
159
186
  });
160
187
  return pollPromise;
161
188
  },