dsh-mobile 0.8.7 → 0.8.9

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/lib/index.d.mts CHANGED
@@ -1351,10 +1351,21 @@ export declare class MobileAccessGateway {
1351
1351
  private proxyHttpOnce;
1352
1352
  private abortSessionResources;
1353
1353
  private broadcastExtensionChange;
1354
- /** Fan a completed task to every phone holding this gateway's event stream. */
1354
+ /**
1355
+ * Fan a completed task to every phone holding this gateway's event stream.
1356
+ *
1357
+ * `sessionId` and `turn` always stay; `title`/`body` are optional and are
1358
+ * emitted together so an older App ignores them and falls back to its own
1359
+ * localized text. This layer re-bounds the strings (code-point safe) instead
1360
+ * of trusting the caller; redaction is deliberately owned only by the
1361
+ * task-events derivation, not repeated here, because this method is also
1362
+ * callable by fixtures that bypass the watcher.
1363
+ */
1355
1364
  broadcastTaskEvent(event: {
1356
1365
  readonly sessionId: string;
1357
1366
  readonly turn: number;
1367
+ readonly title?: string;
1368
+ readonly body?: string;
1358
1369
  }): void;
1359
1370
  /** Notify only the device whose persistent credential was revoked by the Host. */
1360
1371
  private broadcastDeviceRevoked;
@@ -2761,13 +2772,22 @@ export declare function irohGatewayConfig(template: ResolvedGatewayConfig, endpo
2761
2772
  * The exact moment a run ends is known only on the Host (the phone page can
2762
2773
  * merely infer it from UI state), so this module watches the public
2763
2774
  * `session/event` bus for completed root turns and hands them to a hub that
2764
- * fans out to every live mobile gateway. Phones render the text locally, so
2765
- * no user content crosses this boundary — only opaque session and turn ids.
2775
+ * fans out to every live mobile gateway.
2776
+ *
2777
+ * Phones still render the final notification locally, but the event carries an
2778
+ * optional, already-bounded title/body derived from the Session title and the
2779
+ * turn's first non-empty assistant line. The Android background service uses
2780
+ * that text when present and falls back to its localized strings when the
2781
+ * fields are absent, so older Apps that ignore the new fields keep working.
2766
2782
  */
2767
2783
  /** A completed root turn worth announcing on paired phones. */
2768
2784
  interface TaskCompletionEvent {
2769
2785
  readonly sessionId: string;
2770
2786
  readonly turn: number;
2787
+ /** Bounded Session title; present only with a bounded body. */
2788
+ readonly title?: string;
2789
+ /** Bounded first non-empty assistant line of the turn; present only with a title. */
2790
+ readonly body?: string;
2771
2791
  }
2772
2792
  /** Minimal session shape read off the `session/event` bus. */
2773
2793
  interface TaskEventSession {
@@ -2793,6 +2813,8 @@ interface TaskEventWatcherOptions {
2793
2813
  /** Trailing debounce per session that merges turn-boundary bursts. */
2794
2814
  readonly debounceMs?: number;
2795
2815
  readonly onTaskCompleted: (event: TaskCompletionEvent) => void;
2816
+ /** Read the Session's display title (normally `sessionProjections.stateOf(session, 'title')`). */
2817
+ readonly readSessionTitle?: ((session: TaskEventSession) => unknown) | undefined;
2796
2818
  readonly log?: (event: string, fields: Readonly<Record<string, string | number | boolean>>) => void;
2797
2819
  }
2798
2820
  export declare const TASK_EVENT_DEBOUNCE_MS = 1000;
package/lib/index.mjs CHANGED
@@ -1864,6 +1864,200 @@ var MobileAccessGatewayController = class {
1864
1864
  return run;
1865
1865
  }
1866
1866
  };
1867
+ // src/task-events.ts
1868
+ const TASK_EVENT_DEBOUNCE_MS = 1e3;
1869
+ /**
1870
+ * Wire bounds for the optional title/body. They mirror the Android bridge
1871
+ * policy constants (`NativeBridgePolicy.TASK_NOTIFICATION_TITLE_CHARS` = 80,
1872
+ * `TASK_NOTIFICATION_BODY_CHARS` = 200) so the App never truncates a plugin
1873
+ * value again with a different unit.
1874
+ */
1875
+ const TASK_ANNOUNCEMENT_LIMITS = Object.freeze({
1876
+ title: 80,
1877
+ body: 200
1878
+ });
1879
+ /** Bounded number of Sessions whose pending assistant line is retained. */
1880
+ const MAX_PENDING_TEXT = 128;
1881
+ function record(value) {
1882
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
1883
+ }
1884
+ function turnNumber(value) {
1885
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
1886
+ }
1887
+ function turnData(value) {
1888
+ const data = record(value);
1889
+ if (data === void 0) return {
1890
+ turn: 0,
1891
+ completed: false
1892
+ };
1893
+ const reason = data.reason;
1894
+ const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
1895
+ return {
1896
+ turn: turnNumber(data.turn),
1897
+ completed
1898
+ };
1899
+ }
1900
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
1901
+ /**
1902
+ * Any first line matching one of these is discarded whole rather than
1903
+ * partially rewritten. A partial path replacement is how a username or file
1904
+ * name leaks: e.g. a Windows path with a space used to leave the tail behind.
1905
+ * The body comes from a user-visible assistant message, not from logs, but a
1906
+ * model can still quote a path, a URL, a JWT/AWS key or a PEM header.
1907
+ */
1908
+ const SENSITIVE_TEXT_PATTERNS = [
1909
+ /\b(?:https?|file):\/\/\S+/iu,
1910
+ /[A-Za-z]:[\\/][^\r\n"']*/u,
1911
+ /\\\\[^\s"']+/u,
1912
+ /~[\\/][^\r\n"']*/u,
1913
+ /(?:^|[\s"'(])\/(?:[^\s/"']+\/)+[^\s"']*/u,
1914
+ /(?:^|\s)\/[A-Za-z0-9._-]+(?=\s|$)/u,
1915
+ /\beyJ[\w-]{8,}/u,
1916
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/u,
1917
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/u,
1918
+ /\b(?:password|passphrase|token|secret)\s+(?:is|was)\s+\S+/iu,
1919
+ /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{12,}\b/u,
1920
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}\b/u,
1921
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/iu,
1922
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/iu,
1923
+ /\b(?:api[_-]?key|access[_-]?token|token|secret|password|passwd)\s*[:=]\s*['"]?[^\s'"]{4,}/iu
1924
+ ];
1925
+ function boundedText$1(value, limit) {
1926
+ if (typeof value !== "string") return void 0;
1927
+ const normalized = value.normalize("NFC").replace(CONTROL_CHARACTERS, " ").replace(/\s+/gu, " ").trim();
1928
+ if (normalized === "") return void 0;
1929
+ if (SENSITIVE_TEXT_PATTERNS.some((pattern) => pattern.test(normalized))) return void 0;
1930
+ const characters = Array.from(normalized);
1931
+ return characters.length > limit ? characters.slice(0, limit).join("").trim() : normalized;
1932
+ }
1933
+ /**
1934
+ * Build the optional wire text. Both fields are present or both are absent:
1935
+ * a title without a body (or vice versa) would make the App mix plugin text
1936
+ * with its fixed fallback strings.
1937
+ */
1938
+ function taskAnnouncementText(title, body) {
1939
+ const boundedTitle = boundedText$1(title, TASK_ANNOUNCEMENT_LIMITS.title);
1940
+ const boundedBody = boundedText$1(body, TASK_ANNOUNCEMENT_LIMITS.body);
1941
+ if (boundedTitle === void 0 || boundedBody === void 0) return void 0;
1942
+ return Object.freeze({
1943
+ title: boundedTitle,
1944
+ body: boundedBody
1945
+ });
1946
+ }
1947
+ /** Extract the concatenated text parts of an assistant message event. */
1948
+ function assistantMessageText(event) {
1949
+ const content = record(record(event.data)?.message)?.content;
1950
+ if (typeof content === "string") return content === "" ? void 0 : content;
1951
+ if (!Array.isArray(content)) return void 0;
1952
+ let text = "";
1953
+ for (const part of content) {
1954
+ const block = record(part);
1955
+ if (block?.type === "text" && typeof block.text === "string") text += block.text;
1956
+ }
1957
+ return text === "" ? void 0 : text;
1958
+ }
1959
+ function firstNonEmptyLine(value) {
1960
+ if (value === void 0) return void 0;
1961
+ for (const line of value.split(/\r\n|\r|\n/gu)) {
1962
+ const trimmed = line.trim();
1963
+ if (trimmed !== "") return trimmed;
1964
+ }
1965
+ }
1966
+ function sessionTitle(reader, session) {
1967
+ if (reader === void 0) return void 0;
1968
+ try {
1969
+ const value = reader(session);
1970
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1971
+ } catch {
1972
+ return;
1973
+ }
1974
+ }
1975
+ /**
1976
+ * Subscribe to completed root turns. Subagent turns are skipped so one task
1977
+ * announces once, and rapid turn boundaries collapse into a single event.
1978
+ * Returns a disposer that also drops pending debounces.
1979
+ */
1980
+ function watchTaskCompletions(ctx, options) {
1981
+ const debounceMs = options.debounceMs ?? 1e3;
1982
+ const pending = /* @__PURE__ */ new Map();
1983
+ /** First non-empty assistant line of the latest turn per root Session. */
1984
+ const assistantLines = /* @__PURE__ */ new Map();
1985
+ const rememberLine = (sessionId, turn, line) => {
1986
+ if (assistantLines.size >= MAX_PENDING_TEXT && !assistantLines.has(sessionId)) {
1987
+ const oldest = assistantLines.keys().next();
1988
+ if (!oldest.done) assistantLines.delete(oldest.value);
1989
+ }
1990
+ const existing = assistantLines.get(sessionId);
1991
+ if (existing === void 0 || existing.turn !== turn) assistantLines.set(sessionId, {
1992
+ turn,
1993
+ line
1994
+ });
1995
+ };
1996
+ const disposeListener = ctx.on("session/event", (session, event) => {
1997
+ if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
1998
+ const sessionId = String(session.id);
1999
+ if (event.type === "turn/start") {
2000
+ assistantLines.delete(sessionId);
2001
+ return;
2002
+ }
2003
+ if (event.type === "assistant/message") {
2004
+ const turn = turnNumber(record(event.data)?.turn);
2005
+ const line = firstNonEmptyLine(assistantMessageText(event));
2006
+ if (line !== void 0) rememberLine(sessionId, turn, line);
2007
+ return;
2008
+ }
2009
+ if (event.type !== "turn/end") return;
2010
+ const { turn, completed } = turnData(event.data);
2011
+ if (!completed) {
2012
+ assistantLines.delete(sessionId);
2013
+ return;
2014
+ }
2015
+ const pendingLine = assistantLines.get(sessionId);
2016
+ const announcement = taskAnnouncementText(sessionTitle(options.readSessionTitle, session), pendingLine?.turn === turn ? pendingLine.line : void 0);
2017
+ assistantLines.delete(sessionId);
2018
+ const previous = pending.get(sessionId);
2019
+ if (previous !== void 0) clearTimeout(previous);
2020
+ pending.set(sessionId, setTimeout(() => {
2021
+ pending.delete(sessionId);
2022
+ options.log?.("task-completed-announced", {
2023
+ sessionId,
2024
+ turn
2025
+ });
2026
+ options.onTaskCompleted(Object.freeze({
2027
+ sessionId,
2028
+ turn,
2029
+ ...announcement === void 0 ? {} : announcement
2030
+ }));
2031
+ }, debounceMs));
2032
+ });
2033
+ return () => {
2034
+ disposeListener();
2035
+ for (const timer of pending.values()) clearTimeout(timer);
2036
+ pending.clear();
2037
+ assistantLines.clear();
2038
+ };
2039
+ }
2040
+ /** One subscription feeding every live gateway; gateways register on start. */
2041
+ var TaskEventHub = class {
2042
+ sinks = /* @__PURE__ */ new Set();
2043
+ /** Register a sink; returns its disposer. */
2044
+ add(sink) {
2045
+ this.sinks.add(sink);
2046
+ return () => {
2047
+ this.sinks.delete(sink);
2048
+ };
2049
+ }
2050
+ /** Fan out to a snapshot so a failing sink cannot break its siblings. */
2051
+ broadcast(event) {
2052
+ for (const sink of [...this.sinks]) try {
2053
+ sink.broadcastTaskEvent(event);
2054
+ } catch {}
2055
+ }
2056
+ /** Visible for tests. */
2057
+ get size() {
2058
+ return this.sinks.size;
2059
+ }
2060
+ };
1867
2061
  function unwrapBrackets(hostname) {
1868
2062
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1869
2063
  }
@@ -2040,7 +2234,8 @@ function cookie(name, value, options) {
2040
2234
  }
2041
2235
  /** Enforce direct CIDR, exact Host, and browser same-origin facts. */
2042
2236
  function assertExternalTrust(request, policy, requireOrigin) {
2043
- if (!addressAllowed(request.socket.remoteAddress, policy.cidrs) || !policy.acceptsHost(request.headers.host)) throw new HttpError(403, "forbidden");
2237
+ if (!addressAllowed(request.socket.remoteAddress, policy.cidrs)) throw new HttpError(403, "source_network_untrusted");
2238
+ if (!policy.acceptsHost(request.headers.host)) throw new HttpError(403, "forbidden");
2044
2239
  const origin = request.headers.origin;
2045
2240
  if (origin !== void 0 && !policy.acceptsOrigin(origin)) throw new HttpError(403, "forbidden");
2046
2241
  const site = request.headers["sec-fetch-site"];
@@ -9377,14 +9572,28 @@ var MobileAccessGateway = class {
9377
9572
  this.extensionEventRevision += 1;
9378
9573
  for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
9379
9574
  }
9380
- /** Fan a completed task to every phone holding this gateway's event stream. */
9575
+ /**
9576
+ * Fan a completed task to every phone holding this gateway's event stream.
9577
+ *
9578
+ * `sessionId` and `turn` always stay; `title`/`body` are optional and are
9579
+ * emitted together so an older App ignores them and falls back to its own
9580
+ * localized text. This layer re-bounds the strings (code-point safe) instead
9581
+ * of trusting the caller; redaction is deliberately owned only by the
9582
+ * task-events derivation, not repeated here, because this method is also
9583
+ * callable by fixtures that bypass the watcher.
9584
+ */
9381
9585
  broadcastTaskEvent(event) {
9382
9586
  if (this.closing) return;
9383
- const payload = JSON.stringify({
9587
+ const payload = {
9384
9588
  sessionId: String(event.sessionId),
9385
9589
  turn: Number(event.turn) || 0
9386
- });
9387
- for (const listener of this.taskEventListeners) listener(payload);
9590
+ };
9591
+ if (typeof event.title === "string" && event.title !== "" && typeof event.body === "string" && event.body !== "") {
9592
+ payload.title = Array.from(event.title).slice(0, TASK_ANNOUNCEMENT_LIMITS.title).join("");
9593
+ payload.body = Array.from(event.body).slice(0, TASK_ANNOUNCEMENT_LIMITS.body).join("");
9594
+ }
9595
+ const serialized = JSON.stringify(payload);
9596
+ for (const listener of this.taskEventListeners) listener(serialized);
9388
9597
  }
9389
9598
  /** Notify only the device whose persistent credential was revoked by the Host. */
9390
9599
  broadcastDeviceRevoked(deviceId) {
@@ -16900,83 +17109,6 @@ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0)
16900
17109
  pairingTtlMs: 3e5
16901
17110
  });
16902
17111
  }
16903
- // src/task-events.ts
16904
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
16905
- function turnNumber(value) {
16906
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
16907
- }
16908
- function turnData(value) {
16909
- if (value === null || typeof value !== "object" || Array.isArray(value)) return {
16910
- turn: 0,
16911
- completed: false
16912
- };
16913
- const record = value;
16914
- const reason = record.reason;
16915
- const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
16916
- return {
16917
- turn: turnNumber(record.turn),
16918
- completed
16919
- };
16920
- }
16921
- /**
16922
- * Subscribe to completed root turns. Subagent turns are skipped so one task
16923
- * announces once, and rapid turn boundaries collapse into a single event.
16924
- * Returns a disposer that also drops pending debounces.
16925
- */
16926
- function watchTaskCompletions(ctx, options) {
16927
- const debounceMs = options.debounceMs ?? 1e3;
16928
- const pending = /* @__PURE__ */ new Map();
16929
- const disposeListener = ctx.on("session/event", (session, event) => {
16930
- if (event.type !== "turn/end") return;
16931
- const { turn, completed } = turnData(event.data);
16932
- if (!completed) return;
16933
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
16934
- const sessionId = String(session.id);
16935
- options.log?.("task-completed-observed", {
16936
- sessionId,
16937
- turn
16938
- });
16939
- const previous = pending.get(sessionId);
16940
- if (previous !== void 0) clearTimeout(previous);
16941
- pending.set(sessionId, setTimeout(() => {
16942
- pending.delete(sessionId);
16943
- options.log?.("task-completed-announced", {
16944
- sessionId,
16945
- turn
16946
- });
16947
- options.onTaskCompleted(Object.freeze({
16948
- sessionId,
16949
- turn
16950
- }));
16951
- }, debounceMs));
16952
- });
16953
- return () => {
16954
- disposeListener();
16955
- for (const timer of pending.values()) clearTimeout(timer);
16956
- pending.clear();
16957
- };
16958
- }
16959
- /** One subscription feeding every live gateway; gateways register on start. */
16960
- var TaskEventHub = class {
16961
- sinks = /* @__PURE__ */ new Set();
16962
- /** Register a sink; returns its disposer. */
16963
- add(sink) {
16964
- this.sinks.add(sink);
16965
- return () => {
16966
- this.sinks.delete(sink);
16967
- };
16968
- }
16969
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
16970
- broadcast(event) {
16971
- for (const sink of [...this.sinks]) try {
16972
- sink.broadcastTaskEvent(event);
16973
- } catch {}
16974
- }
16975
- /** Visible for tests. */
16976
- get size() {
16977
- return this.sinks.size;
16978
- }
16979
- };
16980
17112
  // src/diagnostic-network-context.ts
16981
17113
  function addressKind(address) {
16982
17114
  if (isIP(address) === 4) {
@@ -21333,6 +21465,74 @@ const OUTCOME_COALESCE_MS = 1e3;
21333
21465
  const ATTENTION_TTL_MS = 3e5;
21334
21466
  const OUTCOME_TTL_MS = 18e5;
21335
21467
  const ATTEMPT_TIMEOUT_MS = 1e4;
21468
+ // src/mobile-push/ownership.ts
21469
+ const MAX_OWNERSHIP_DEPTH = 64;
21470
+ /**
21471
+ * Read the durable header from the object a public event delivered.
21472
+ *
21473
+ * A `session/event` observer receives the Session itself, whose header lives on
21474
+ * the live Agent; a request can carry an Agent-shaped identity without the
21475
+ * Session attached. Resolve both through the public registry instead of
21476
+ * assuming one shape.
21477
+ */
21478
+ function durableHeader(registry, agent) {
21479
+ const direct = agent.session?.header;
21480
+ if (direct !== void 0) return direct;
21481
+ return registry?.get?.(agent.id)?.session?.header;
21482
+ }
21483
+ /**
21484
+ * Find the owning parent recorded by the durable Session header.
21485
+ *
21486
+ * Only a header that explicitly classifies the Session as a subagent child
21487
+ * creates ownership. Historical ordinary forks may retain a `parentSession`
21488
+ * source id, so the field alone is not evidence of ownership.
21489
+ */
21490
+ function owningParentId(registry, agent) {
21491
+ const header = durableHeader(registry, agent);
21492
+ if (header?.origin !== "subagent") return void 0;
21493
+ const parent = header.parentSession;
21494
+ return typeof parent === "string" && parent.length > 0 ? parent : void 0;
21495
+ }
21496
+ /** The public ownership walk available before DSH 0.2.2-rc.2, when it exists. */
21497
+ function legacyOwnerOf(registry) {
21498
+ if (typeof registry?.list !== "function" || typeof registry.isOwnedBy !== "function") return void 0;
21499
+ return (id) => (registry?.list?.() ?? []).find((candidate) => registry?.isOwnedBy?.(id, candidate) === true);
21500
+ }
21501
+ /**
21502
+ * Whether this Agent is a runtime child that must not notify on its own.
21503
+ *
21504
+ * Without the removed registry ownership API, the live parent Agent recorded
21505
+ * by the header is the public evidence; an orphan does not count as owned,
21506
+ * matching the earlier walk that stopped at a missing owner.
21507
+ */
21508
+ function isPushRuntimeChild(registry, agent) {
21509
+ const ownerOf = legacyOwnerOf(registry);
21510
+ if (ownerOf !== void 0) return ownerOf(agent.id) !== void 0;
21511
+ const parentId = owningParentId(registry, agent);
21512
+ return parentId !== void 0 && registry?.get?.(parentId) !== void 0;
21513
+ }
21514
+ /**
21515
+ * Resolve the root Session a notification should target.
21516
+ *
21517
+ * Uses the removed `isOwnedBy` walk when the host still exposes it, and the
21518
+ * durable subagent header ancestry otherwise. A child whose parent Agent is no
21519
+ * longer live remains its own root, matching the previous behavior.
21520
+ */
21521
+ function pushRootAgent(registry, agent) {
21522
+ const ownerOf = legacyOwnerOf(registry);
21523
+ let current = agent;
21524
+ for (let count = 0; count < MAX_OWNERSHIP_DEPTH; count++) {
21525
+ let parent;
21526
+ if (ownerOf !== void 0) parent = ownerOf(current.id);
21527
+ else {
21528
+ const parentId = owningParentId(registry, current);
21529
+ parent = parentId === void 0 ? void 0 : registry?.get?.(parentId);
21530
+ }
21531
+ if (parent === void 0 || parent.id === current.id) return current;
21532
+ current = parent;
21533
+ }
21534
+ return current;
21535
+ }
21336
21536
  // src/mobile-push/requests.ts
21337
21537
  var RequestLifetimes = class {
21338
21538
  #records = /* @__PURE__ */ new Map();
@@ -21494,15 +21694,7 @@ var RequestLifetimes = class {
21494
21694
  return rec;
21495
21695
  }
21496
21696
  #root(agent) {
21497
- const registry = this.#ctx.agents;
21498
- if (!registry?.isOwnedBy || !registry.get || !registry.list) return agent;
21499
- let current = agent;
21500
- for (let count = 0; count < 64; count++) {
21501
- const parent = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
21502
- if (!parent) break;
21503
- current = parent;
21504
- }
21505
- return current;
21697
+ return pushRootAgent(this.#ctx.agents, agent);
21506
21698
  }
21507
21699
  #arm(rec) {
21508
21700
  if (rec.timer) return;
@@ -21677,20 +21869,10 @@ var OutcomeLifetimes = class {
21677
21869
  }
21678
21870
  }
21679
21871
  #isRuntimeChild(agent) {
21680
- const registry = this.#ctx.agents;
21681
- if (!registry?.isOwnedBy || !registry.list) return false;
21682
- return registry.list().some((owner) => registry.isOwnedBy?.(agent.id, owner));
21872
+ return isPushRuntimeChild(this.#ctx.agents, agent);
21683
21873
  }
21684
21874
  #root(agent) {
21685
- const registry = this.#ctx.agents;
21686
- if (!registry?.isOwnedBy || !registry.list) return agent;
21687
- let current = agent;
21688
- for (let count = 0; count < 64; count++) {
21689
- const owner = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
21690
- if (!owner) return current;
21691
- current = owner;
21692
- }
21693
- return current;
21875
+ return pushRootAgent(this.#ctx.agents, agent);
21694
21876
  }
21695
21877
  };
21696
21878
  // src/mobile-push/reconcile.ts
@@ -21967,7 +22149,7 @@ var PushReconciler = class {
21967
22149
  };
21968
22150
  // src/mobile-push/host.ts
21969
22151
  function hasContract(ctx) {
21970
- return typeof ctx.on === "function" && typeof ctx.sessionProjections?.stateOf === "function" && typeof ctx.sessionProjections.snapshot === "function" && typeof ctx.sessionProjections.register === "function" && typeof ctx.sessionQuery?.observeSession === "function" && typeof ctx.agents?.get === "function" && typeof ctx.agents.list === "function" && typeof ctx.agents.roots === "function" && typeof ctx.agents.isOwnedBy === "function" && ctx.userQuestions !== void 0 && ctx.approval !== void 0;
22152
+ return typeof ctx.on === "function" && typeof ctx.sessionProjections?.stateOf === "function" && typeof ctx.sessionProjections.snapshot === "function" && typeof ctx.sessionProjections.register === "function" && typeof ctx.sessionQuery?.observeSession === "function" && typeof ctx.agents?.get === "function" && typeof ctx.agents.list === "function" && ctx.userQuestions !== void 0 && ctx.approval !== void 0;
21971
22153
  }
21972
22154
  const MAINTENANCE_INTERVAL_MS = 3e4;
21973
22155
  function createPushHost(ctx) {
@@ -24123,6 +24305,7 @@ var PushDelivery = class {
24123
24305
  if (this.#closed) return;
24124
24306
  if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
24125
24307
  this.#wakeTimer = void 0;
24308
+ if (this.#active >= 4) return;
24126
24309
  this.#pruneDeferred();
24127
24310
  let nextAt = Number.POSITIVE_INFINITY;
24128
24311
  try {
@@ -24901,6 +25084,9 @@ async function apply(ctx, config) {
24901
25084
  const taskEventHub = new TaskEventHub();
24902
25085
  const disposeTaskEvents = watchTaskCompletions(ctx, {
24903
25086
  onTaskCompleted: (event) => taskEventHub.broadcast(event),
25087
+ readSessionTitle: (session) => {
25088
+ return (ctx.get?.("sessionProjections"))?.stateOf?.(session, "title");
25089
+ },
24904
25090
  log(event, fields) {
24905
25091
  logger.info("task event=%s fields=%o", event, fields);
24906
25092
  }