@north-light/crouter 0.3.306 → 0.3.308

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.
@@ -128,84 +128,153 @@ const BROKER_SIGKILL_GRACE_MS = 2_000;
128
128
  * full grace ceilings above are reserved for the genuinely-wedged case,
129
129
  * where the added latency is the whole point. */
130
130
  const TEARDOWN_POLL_MS = 150;
131
- /** Poll `isAlive` every `TEARDOWN_POLL_MS` until it reports dead. If it is
132
- * STILL alive once `timeoutMs` elapses, fire `onTimeout` exactly once (the
133
- * next escalation rung) and keep polling so the caller can confirm that
134
- * rung actually worked — up to `hardCeilingMs` total, after which this gives
135
- * up (an unkillable/D-state descendant is out of scope for this primitive).
136
- *
137
- * Deliberately a recursive, NEVER-`.unref()`'d `setTimeout` rather than a
138
- * fixed unref'd wait: most teardown callers
139
- * are short-lived CLI subprocesses (`node lifecycle close`, `canvas prune`)
140
- * that would otherwise exit before an unref'd SIGKILL timer ever fires,
141
- * silently skipping the last-resort rung. Polling (instead of a blind fixed
142
- * wait) is what keeps the happy path cheap despite being ref'd. */
143
- function pollUntil(isAlive, timeoutMs, onTimeout, hardCeilingMs = timeoutMs + 5_000) {
144
- const start = Date.now();
145
- let escalated = false;
146
- const tick = () => {
147
- if (!isAlive())
148
- return; // confirmed dead — stop polling
149
- const elapsed = Date.now() - start;
150
- if (!escalated && elapsed >= timeoutMs) {
151
- escalated = true;
152
- onTimeout();
153
- }
154
- if (elapsed >= hardCeilingMs)
155
- return; // best-effort exhausted — give up
156
- setTimeout(tick, TEARDOWN_POLL_MS);
157
- };
158
- tick();
131
+ /** Wait against the fixed snapshot. The ref'd timer keeps a short-lived
132
+ * caller alive through the final SIGKILL confirmation. */
133
+ async function waitForTreeExit(tree, timeoutMs) {
134
+ const deadline = Date.now() + timeoutMs;
135
+ while (isAnyPidAlive(tree)) {
136
+ const remaining = deadline - Date.now();
137
+ if (remaining <= 0)
138
+ return false;
139
+ await new Promise((resolve) => setTimeout(resolve, Math.min(TEARDOWN_POLL_MS, remaining)));
140
+ }
141
+ return true;
159
142
  }
160
- /** Escalate teardown of a broker/tree that did not exit cleanly off the
161
- * graceful `shutdown` frame (or was never reachable at all): SIGTERM the
162
- * whole tree, then — once `BROKER_SIGKILL_GRACE_MS` has elapsed with the tree
163
- * still alive — SIGKILL the whole tree as the last resort.
164
- *
165
- * Takes an ALREADY-CAPTURED `tree` (pid list) and `identities` (a
166
- * `captureTeardownSnapshot` identity map over that same list) — both taken
167
- * ONCE by `teardown()` synchronously at its very top, before EITHER
168
- * escalation path (connect-error or post-shutdown-frame) can run, and
169
- * threaded through here unchanged. This
170
- * function must NEVER re-walk the process tree itself: `launch()` spawns the
171
- * broker `detached: true`, so
172
- * signaling the broker's OWN group (the first thing `killProcessTreePids`
173
- * does) typically kills the broker itself almost immediately — and the
174
- * kernel reparents any surviving child away from the broker's pid the
175
- * INSTANT the broker exits, severing the very ppid link a fresh
176
- * `descendantPids` walk depends on. Re-deriving descendants at ANY point
177
- * after the broker may have exited (including a per-rung re-walk here, or a
178
- * poll that re-derives instead of checking this fixed list) would silently
179
- * "lose" a still-alive detached descendant — e.g. the pi bash tool's shell
180
- * child, itself `detached: true` and thus its own group leader, distinct
181
- * from the broker's group, so a plain `kill(-brokerPid)` never reaches it
182
- * either. `identities` guards each signal against
183
- * PID REUSE across the multi-second escalation window —
184
- * see `killProcessTreePids`. `null` means the initial `captureTeardownSnapshot`
185
- * probe itself failed (no baseline to compare against) — `killProcessTreePids`
186
- * falls through to its normal unguarded signal path in that case, never
187
- * reading a probe failure as proof the pid is gone or reused. */
188
- function escalateBrokerTeardown(tree, identities) {
143
+ /** Finish one teardown using the snapshot captured before any signal can sever
144
+ * broker parentage. A failed confirmation is explicit so post-teardown cleanup
145
+ * never runs against an engine tree that may still own its resources. */
146
+ async function terminateBrokerTree(tree, identities, graceful) {
147
+ if (tree.length === 0)
148
+ return { confirmed: true };
149
+ if (graceful && await waitForTreeExit(tree, BROKER_SHUTDOWN_GRACE_MS))
150
+ return { confirmed: true };
189
151
  killProcessTreePids(tree, 'SIGTERM', identities);
190
- pollUntil(() => isAnyPidAlive(tree), BROKER_SIGKILL_GRACE_MS, () => killProcessTreePids(tree, 'SIGKILL', identities));
152
+ if (await waitForTreeExit(tree, BROKER_SIGKILL_GRACE_MS))
153
+ return { confirmed: true };
154
+ killProcessTreePids(tree, 'SIGKILL', identities);
155
+ if (await waitForTreeExit(tree, 5_000))
156
+ return { confirmed: true };
157
+ return { confirmed: false, error: new Error(`broker process tree for ${tree.join(', ')} remained alive after SIGKILL`) };
158
+ }
159
+ /** Capture the one authoritative process snapshot before a caller can reap the
160
+ * node row. No signal or socket operation occurs here; execution remains queued
161
+ * until the close response has settled. */
162
+ function prepareBrokerTeardown(nodeId, opts) {
163
+ const node = getNode(nodeId);
164
+ const pid = node?.pi_pid;
165
+ const snapshot = pid != null
166
+ ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid)
167
+ : null;
168
+ if (snapshot?.reused === true) {
169
+ emitEvent({
170
+ level: 'warn',
171
+ event: 'broker.teardown.pid_reuse_refused',
172
+ node_id: nodeId,
173
+ fields: { recorded_pid: pid, expected_identity: node?.pi_pid_identity ?? null },
174
+ });
175
+ }
176
+ return {
177
+ tree: snapshot?.tree ?? [],
178
+ identities: snapshot?.identities ?? null,
179
+ sockPath: viewSocketPath(nodeId),
180
+ };
181
+ }
182
+ async function runPreparedBrokerTeardown(prepared) {
183
+ // Even a dead broker pid can still be the process-group leader for an orphan
184
+ // that the initial ppid walk could no longer reach. The connect-failure path
185
+ // must retain that leader id for group signaling rather than treating its dead
186
+ // status as confirmation that no descendant survives.
187
+ return new Promise((resolve) => {
188
+ let connected = false;
189
+ let settled = false;
190
+ const settle = (result) => {
191
+ if (settled)
192
+ return;
193
+ settled = true;
194
+ resolve(result);
195
+ };
196
+ const cleanupSocket = () => {
197
+ try {
198
+ if (existsSync(prepared.sockPath))
199
+ unlinkSync(prepared.sockPath);
200
+ }
201
+ catch {
202
+ /* best-effort cleanup */
203
+ }
204
+ };
205
+ const finish = (graceful) => {
206
+ void terminateBrokerTree(prepared.tree, prepared.identities, graceful).then(settle, (error) => settle({ confirmed: false, error: error instanceof Error ? error : new Error(String(error)) }));
207
+ };
208
+ const sock = connect(prepared.sockPath);
209
+ sock.once('error', () => {
210
+ if (connected)
211
+ return;
212
+ cleanupSocket();
213
+ finish(false);
214
+ });
215
+ sock.once('connect', () => {
216
+ connected = true;
217
+ try {
218
+ sock.write(encodeFrame({ type: 'shutdown' }));
219
+ }
220
+ catch {
221
+ /* the broker may have raced us to exit */
222
+ }
223
+ sock.end();
224
+ cleanupSocket();
225
+ finish(true);
226
+ });
227
+ });
191
228
  }
192
229
  const requestedTeardowns = new Map();
193
- /** Queue broker teardown onto the daemon event loop after the close response can
194
- * settle. Repeated terminal reconciliation coalesces on the same node. */
195
- export function requestBrokerTeardown(nodeId, opts = {}) {
230
+ /** Queue broker teardown after the close response settles. Completion includes
231
+ * any post-teardown work, which lets the daemon drain it during shutdown. */
232
+ export function requestBrokerTeardown(nodeId, opts = {}, after) {
196
233
  const existing = requestedTeardowns.get(nodeId);
197
234
  if (existing !== undefined) {
198
- if (existing.excludePid === undefined && opts.excludePid !== undefined)
199
- existing.excludePid = opts.excludePid;
200
- return;
235
+ if (after !== undefined)
236
+ existing.after.push(after);
237
+ return existing.completion;
238
+ }
239
+ let resolve;
240
+ const completion = new Promise((done) => { resolve = done; });
241
+ const request = { completion, resolve, after: after === undefined ? [] : [after] };
242
+ requestedTeardowns.set(nodeId, request);
243
+ let prepared;
244
+ try {
245
+ prepared = prepareBrokerTeardown(nodeId, opts);
246
+ }
247
+ catch (error) {
248
+ const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
249
+ requestedTeardowns.delete(nodeId);
250
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
251
+ request.resolve(failure);
252
+ return completion;
201
253
  }
202
- requestedTeardowns.set(nodeId, { ...opts });
203
254
  setImmediate(() => {
204
- const request = requestedTeardowns.get(nodeId);
205
255
  requestedTeardowns.delete(nodeId);
206
- if (request !== undefined)
207
- headlessBrokerHost.teardown(nodeId, request);
256
+ void runPreparedBrokerTeardown(prepared).then(async (result) => {
257
+ if (result.confirmed) {
258
+ for (const callback of request.after) {
259
+ try {
260
+ await callback();
261
+ }
262
+ catch (error) {
263
+ emitEvent({ level: 'error', event: 'broker.teardown.after_failed', node_id: nodeId, error });
264
+ }
265
+ }
266
+ }
267
+ else {
268
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: result.error });
269
+ }
270
+ request.resolve(result);
271
+ }, (error) => {
272
+ const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
273
+ emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
274
+ request.resolve(failure);
275
+ });
208
276
  });
277
+ return completion;
209
278
  }
210
279
  export const headlessBrokerHost = {
211
280
  launch(nodeId, inv, opts) {
@@ -364,117 +433,15 @@ export const headlessBrokerHost = {
364
433
  return isPidAlive((typeof node === 'string' ? getNode(node) : node)?.pi_pid);
365
434
  },
366
435
  teardown(nodeId, opts = {}) {
367
- // Graceful: connect to view.sock + send a `shutdown` frame → the broker
368
- // dispose()s the engine, unlinks the socket, exits 0. Status is already
369
- // flipped done/canceled by the caller (crash-safe ordering), so the daemon
370
- // won't revive. On connect failure (broker dead/crashed/wedged) fall back
371
- // to escalateBrokerTeardown — a tree-wide SIGTERM now, a tree-wide SIGKILL
372
- // as the last resort — so no orphaned descendant of a dead/wedged broker
373
- // survives the node — + unlink the stale socket.
374
- //
375
- // Capture the broker pid ONCE, synchronously, right here — never re-query
376
- // `getNode(nodeId)` inside the async callbacks/timers below. Callers such
377
- // as placement.ts's `reapIfEmpty()` call `teardown()` then immediately
378
- // `deleteNode()` in the same tick; by the time an async callback fires the
379
- // row can already be gone, so a re-query would silently resolve to
380
- // `undefined` and skip escalation entirely.
381
- const node = getNode(nodeId);
382
- const pid = node?.pi_pid;
383
- // Snapshot the full descendant TREE — the
384
- // root pid plus every transitive descendant — and a stable identity
385
- // fingerprint for every pid in it, BOTH from ONE `ps`
386
- // table read (`captureTeardownSnapshot`, final review: closes the small
387
- // race between two separate probes), exactly ONCE, right here, while the
388
- // broker (if alive) still holds parentage over any detached descendant.
389
- // EVERY escalation path below — connect-error AND post-shutdown-frame —
390
- // reuses this ONE fixed snapshot; neither may re-walk `ps` by ppid once
391
- // the broker might have exited, because the kernel reparents any
392
- // surviving child away from `pid` the INSTANT the broker exits, severing
393
- // the ppid link a fresh walk depends on — exactly the reparenting hazard
394
- // this fix closes. (If the broker is ALREADY dead when `teardown()`
395
- // starts, its detached descendants were already reparented before we ever
396
- // got here — unrecoverable by ppid, and out of scope: the #98 repro is
397
- // the wedged-but-ALIVE broker, which this covers.)
398
- //
399
- // `node?.pi_pid_identity` is the LAUNCH-time baseline `recordPid` captured
400
- // the instant this broker was spawned/bound (final review, Fix 1): if
401
- // `pid` has ALREADY been recycled for an unrelated process before
402
- // teardown ever ran — the pid-reuse hazard this guard exists for — the
403
- // snapshot's `reused: true` comes back with an EMPTY tree, so nothing
404
- // below ever signals the stranger (or walks its process tree looking for
405
- // "descendants", which would be the stranger's children, not ours).
406
- const snapshot = pid != null ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid) : null;
407
- if (snapshot?.reused === true) {
408
- emitEvent({
409
- level: 'warn',
410
- event: 'broker.teardown.pid_reuse_refused',
411
- node_id: nodeId,
412
- fields: {
413
- recorded_pid: pid,
414
- expected_identity: node?.pi_pid_identity ?? null,
415
- },
436
+ try {
437
+ return runPreparedBrokerTeardown(prepareBrokerTeardown(nodeId, opts));
438
+ }
439
+ catch (error) {
440
+ return Promise.resolve({
441
+ confirmed: false,
442
+ error: error instanceof Error ? error : new Error(String(error)),
416
443
  });
417
444
  }
418
- const tree = snapshot?.tree ?? [];
419
- const identities = snapshot?.identities ?? null;
420
- const sockPath = viewSocketPath(nodeId);
421
- const cleanupSocket = () => {
422
- try {
423
- if (existsSync(sockPath))
424
- unlinkSync(sockPath);
425
- }
426
- catch {
427
- /* best-effort cleanup */
428
- }
429
- };
430
- const sock = connect(sockPath);
431
- let connected = false;
432
- sock.on('error', () => {
433
- // A post-connect error (broker exiting after we sent shutdown) is benign
434
- // and swallowed; only a connect failure triggers the escalation fallback.
435
- if (connected)
436
- return;
437
- if (tree.length > 0)
438
- escalateBrokerTeardown(tree, identities);
439
- cleanupSocket();
440
- });
441
- sock.once('connect', () => {
442
- connected = true;
443
- try {
444
- sock.write(encodeFrame({ type: 'shutdown' }));
445
- }
446
- catch {
447
- /* the broker may have raced us to exit */
448
- }
449
- sock.end();
450
- cleanupSocket();
451
- // Bounded exit confirmation: a broker that connects fine but then HANGS
452
- // inside session.dispose() (disposeAndExit catches a throw, not a hang)
453
- // would leak the process holding the sole .jsonl writer — and any
454
- // bash→test→app descendants it spawned along the way. Poll
455
- // the whole process TREE (not just the broker's own pid) for up to
456
- // `BROKER_SHUTDOWN_GRACE_MS`, UNCONDITIONAL on the broker's own liveness:
457
- // a broker that exits cleanly can still
458
- // leave a live descendant behind (dispose() partially failed, or never
459
- // reached a detached SDK child), which is exactly the shape this
460
- // closes. `pollUntil` is ref'd — not `.unref()`'d — so a short-lived
461
- // CLI caller stays alive long enough for the SIGKILL rung to actually
462
- // fire, while the happy path (tree already dead) still pays only a
463
- // couple of poll ticks.
464
- //
465
- // Checks `isAnyPidAlive(tree)` against the FIXED pre-signal snapshot,
466
- // NEVER a fresh `isProcessTreeAlive`/`descendantPids` re-walk: the broker
467
- // can exit at any point during this
468
- // grace window while leaving a detached descendant alive, and the
469
- // instant it does, the kernel reparents that descendant away from `pid`
470
- // — a re-walk-by-ppid would then find nothing and silently skip
471
- // escalation, even though the descendant is still very much alive. The
472
- // fixed `tree` snapshot (taken before the shutdown frame was even sent)
473
- // has no such blind spot.
474
- if (tree.length > 0) {
475
- pollUntil(() => isAnyPidAlive(tree), BROKER_SHUTDOWN_GRACE_MS, () => escalateBrokerTeardown(tree, identities));
476
- }
477
- });
478
445
  },
479
446
  signal(nodeId, sig) {
480
447
  const pid = getNode(nodeId)?.pi_pid;
@@ -86,7 +86,7 @@ export declare function detachToBackground(nodeId: string, pane?: string): boole
86
86
  * its node identity so the worktree remains owned and can later be closed.
87
87
  * Returns true when it reaped. No-op (false) on a node that produced output,
88
88
  * is mid-first-turn, owns an open managed worktree, or no longer exists. */
89
- export declare function reapIfEmpty(nodeId: string, keepPane?: string): boolean;
89
+ export declare function reapIfEmpty(nodeId: string, keepPane?: string, teardown?: boolean): boolean;
90
90
  /** Sweep the whole canvas for empty shells and reap them — the bulk cleanup
91
91
  * behind `crtr canvas prune --empty`, for the husks that accumulated before
92
92
  * close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), dead rows,
@@ -342,11 +342,12 @@ function isEmptyNode(nodeId) {
342
342
  * its node identity so the worktree remains owned and can later be closed.
343
343
  * Returns true when it reaped. No-op (false) on a node that produced output,
344
344
  * is mid-first-turn, owns an open managed worktree, or no longer exists. */
345
- export function reapIfEmpty(nodeId, keepPane) {
345
+ export function reapIfEmpty(nodeId, keepPane, teardown = true) {
346
346
  const meta = getNode(nodeId);
347
347
  if (meta?.managed_worktree?.state === 'open' || !isEmptyNode(nodeId))
348
348
  return false;
349
- headlessBrokerHost.teardown(nodeId);
349
+ if (teardown)
350
+ void headlessBrokerHost.teardown(nodeId);
350
351
  tearDownNode(nodeId, keepPane);
351
352
  return deleteNode(nodeId).deleted;
352
353
  }
@@ -47,6 +47,7 @@ import { fullName } from '../../../core/canvas/labels.js';
47
47
  import { reportsForNode } from './reports.js';
48
48
  import { getCron } from '../../../core/canvas/crons.js';
49
49
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
50
+ import { boundDaemonControl } from '../../control.js';
50
51
  import { notFound, usage } from '../../../core/errors.js';
51
52
  import { toCloseResultDTO, toNodeDetailDTO, toNodeSummaryDTO, toReviveResultDTO } from '../map.js';
52
53
  import { ApiError } from '../../../api/index.js';
@@ -604,9 +605,11 @@ function handleClose(ctx) {
604
605
  if (body?.caller_pid !== undefined && (!Number.isInteger(body.caller_pid) || body.caller_pid <= 0)) {
605
606
  throw usage('caller_pid must be a positive integer when provided');
606
607
  }
608
+ const daemon = boundDaemonControl();
607
609
  const result = closeNode(id, {
608
610
  rootEvent: body?.finish === true ? 'finish' : 'cancel',
609
611
  callerPid: body?.caller_pid,
612
+ ...(daemon === null ? {} : { registerDetached: daemon.registerDetached }),
610
613
  });
611
614
  return { status: 200, body: toCloseResultDTO(result) };
612
615
  }
@@ -12,6 +12,8 @@ export interface DaemonControl {
12
12
  requestHandover(): {
13
13
  graceMs: number;
14
14
  };
15
+ /** Register daemon-owned post-response work for shutdown draining. */
16
+ registerDetached(work: Promise<void>): void;
15
17
  }
16
18
  /** Bind this process's control surface. Called once, by `runDaemon`. */
17
19
  export declare function bindDaemonControl(control: DaemonControl): void;
@@ -607,7 +607,7 @@ export async function runDaemon(opts = {}) {
607
607
  // The self-control seam the API's handover handler reaches this closure
608
608
  // through (see control.ts). Bound alongside the fleet so anything that can
609
609
  // launch a broker can also hand the fleet over.
610
- bindDaemonControl({ epoch, requestHandover });
610
+ bindDaemonControl({ epoch, requestHandover, registerDetached });
611
611
  operationIdContext.fresh(() => {
612
612
  emitEvent({
613
613
  level: 'info',
@@ -43,11 +43,11 @@ export interface AfterHookRequest extends HookRequestBase {
43
43
  export interface ReplaceHookRequest extends HookRequestBase {
44
44
  phase: 'replace';
45
45
  }
46
- /** Lifecycle handlers run on every matching event. They must be idempotent because `node:start` fires for birth and every revive. */
46
+ /** Lifecycle handlers run on every matching event. `node:start` handlers must be idempotent because they fire for birth and every revive. */
47
47
  export interface LifecycleHookRequest {
48
48
  protocolVersion: 1;
49
49
  op: string;
50
- event: 'node:start';
50
+ event: 'node:start' | 'node:close';
51
51
  phase: 'on';
52
52
  operationId: string;
53
53
  node: LifecycleHookNode;
@@ -249,7 +249,7 @@ function validateRequest(value) {
249
249
  if (value['phase'] === 'on') {
250
250
  if (!hasOnlyKeys(value, ['protocolVersion', 'op', 'event', 'phase', 'operationId', 'node', 'runtime', 'context']))
251
251
  return invalid('lifecycle request contains unknown fields');
252
- if (value['event'] !== 'node:start')
252
+ if (value['event'] !== 'node:start' && value['event'] !== 'node:close')
253
253
  return invalid('unsupported lifecycle event');
254
254
  if (!isLifecycleNode(value['node']))
255
255
  return invalid('lifecycle request node contains invalid fields');
@@ -260,7 +260,7 @@ function validateRequest(value) {
260
260
  return valid({
261
261
  protocolVersion: 1,
262
262
  op: value['op'],
263
- event: 'node:start',
263
+ event: value['event'],
264
264
  phase: 'on',
265
265
  operationId: value['operationId'],
266
266
  node: value['node'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.306",
3
+ "version": "0.3.308",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.306",
3
+ "version": "0.3.308",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.306",
9
+ "version": "0.3.308",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.306",
5209
+ "version": "0.3.308",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.306",
5214
+ "version": "0.3.308",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.306",
5219
+ "version": "0.3.308",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"