dsh-mobile 0.8.6 → 0.8.8

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
  }
@@ -2156,6 +2350,8 @@ function requestPathCategory(path) {
2156
2350
  case "/mobile-access/events": return path;
2157
2351
  default:
2158
2352
  if (path.startsWith("/api/")) return "/api/*";
2353
+ if (path.startsWith("/plugins/")) return "/plugins/*";
2354
+ if (path.startsWith("/assets/")) return "/assets/*";
2159
2355
  if (path.startsWith("/mobile-access/extensions/")) return "/mobile-access/extensions/*";
2160
2356
  if (/^\/mobile-access\/compat-[a-f\d]{64}\.js$/u.test(path)) return "/mobile-access/compat.js";
2161
2357
  if (path.startsWith("/mobile-access/")) return "/mobile-access/*";
@@ -3278,7 +3474,7 @@ function forwardableClose(code) {
3278
3474
  * @param upstreamCookie - gateway's signed upstream cookie; browser cookies are discarded.
3279
3475
  * @param options - resolved message, queue, and compression limits.
3280
3476
  * @param handshakeTimeoutMs - maximum handshake and close-handshake duration.
3281
- * @returns fulfillment after both HTTP upgrades are accepted, or rejection before acceptance.
3477
+ * @returns the close handle after both HTTP upgrades are accepted, or rejection before acceptance.
3282
3478
  */
3283
3479
  function bridgeCompressedWebSocket(request, client, upstream, head, target, upstreamCookie, options, handshakeTimeoutMs) {
3284
3480
  return new Promise((resolve, reject) => {
@@ -3467,7 +3663,7 @@ function bridgeCompressedWebSocket(request, client, upstream, head, target, upst
3467
3663
  accepted = true;
3468
3664
  clearTimeout(handshakeTimer);
3469
3665
  incoming.resume();
3470
- resolve();
3666
+ resolve({ close: beginClose });
3471
3667
  });
3472
3668
  } catch (error) {
3473
3669
  stop(error instanceof Error ? error : /* @__PURE__ */ new Error("Client WebSocket upgrade failed"));
@@ -9375,14 +9571,28 @@ var MobileAccessGateway = class {
9375
9571
  this.extensionEventRevision += 1;
9376
9572
  for (const listener of this.extensionEventListeners) listener(this.extensionEventRevision);
9377
9573
  }
9378
- /** Fan a completed task to every phone holding this gateway's event stream. */
9574
+ /**
9575
+ * Fan a completed task to every phone holding this gateway's event stream.
9576
+ *
9577
+ * `sessionId` and `turn` always stay; `title`/`body` are optional and are
9578
+ * emitted together so an older App ignores them and falls back to its own
9579
+ * localized text. This layer re-bounds the strings (code-point safe) instead
9580
+ * of trusting the caller; redaction is deliberately owned only by the
9581
+ * task-events derivation, not repeated here, because this method is also
9582
+ * callable by fixtures that bypass the watcher.
9583
+ */
9379
9584
  broadcastTaskEvent(event) {
9380
9585
  if (this.closing) return;
9381
- const payload = JSON.stringify({
9586
+ const payload = {
9382
9587
  sessionId: String(event.sessionId),
9383
9588
  turn: Number(event.turn) || 0
9384
- });
9385
- for (const listener of this.taskEventListeners) listener(payload);
9589
+ };
9590
+ if (typeof event.title === "string" && event.title !== "" && typeof event.body === "string" && event.body !== "") {
9591
+ payload.title = Array.from(event.title).slice(0, TASK_ANNOUNCEMENT_LIMITS.title).join("");
9592
+ payload.body = Array.from(event.body).slice(0, TASK_ANNOUNCEMENT_LIMITS.body).join("");
9593
+ }
9594
+ const serialized = JSON.stringify(payload);
9595
+ for (const listener of this.taskEventListeners) listener(serialized);
9386
9596
  }
9387
9597
  /** Notify only the device whose persistent credential was revoked by the Host. */
9388
9598
  broadcastDeviceRevoked(deviceId) {
@@ -9623,9 +9833,31 @@ var MobileAccessGateway = class {
9623
9833
  client.destroy();
9624
9834
  upstream.destroy();
9625
9835
  };
9836
+ let closeCompressedBridge;
9837
+ const finishGracefully = () => {
9838
+ if (closeCompressedBridge !== void 0) closeCompressedBridge(1e3, "session expired");
9839
+ else {
9840
+ upstream.unpipe(client);
9841
+ client.unpipe(upstream);
9842
+ upstream.pause();
9843
+ const sendCloseFrame = (socket) => {
9844
+ if (socket.destroyed || !socket.writable) return;
9845
+ try {
9846
+ socket.write(Buffer.from([
9847
+ 136,
9848
+ 2,
9849
+ 3,
9850
+ 232
9851
+ ]));
9852
+ } catch {}
9853
+ };
9854
+ sendCloseFrame(client);
9855
+ }
9856
+ setTimeout(closeBoth, 50).unref();
9857
+ };
9626
9858
  client.on("error", closeBoth);
9627
9859
  upstream.on("error", closeBoth);
9628
- const timer = setTimeout(closeBoth, Math.max(1, authorization.expiresAt - Date.now()));
9860
+ const timer = setTimeout(finishGracefully, Math.max(1, authorization.expiresAt - Date.now()));
9629
9861
  timer.unref();
9630
9862
  const record = Object.freeze({
9631
9863
  ...authorization,
@@ -9654,7 +9886,10 @@ var MobileAccessGateway = class {
9654
9886
  const compressionTarget = new URL(this.config.upstreamOrigin.href);
9655
9887
  compressionTarget.pathname = target.pathname;
9656
9888
  compressionTarget.search = target.search;
9657
- await bridgeCompressedWebSocket(request, client, upstream, head, compressionTarget, upstreamCookie, this.config.websocketCompression, this.config.upstreamTimeoutMs);
9889
+ const bridge = await bridgeCompressedWebSocket(request, client, upstream, head, compressionTarget, upstreamCookie, this.config.websocketCompression, this.config.upstreamTimeoutMs);
9890
+ closeCompressedBridge = (code, reason) => {
9891
+ bridge.close(code, reason);
9892
+ };
9658
9893
  return;
9659
9894
  }
9660
9895
  await new Promise((resolve, reject) => {
@@ -16873,83 +17108,6 @@ function irohGatewayConfig(template, endpointId, stateFile, tls, listenPort = 0)
16873
17108
  pairingTtlMs: 3e5
16874
17109
  });
16875
17110
  }
16876
- // src/task-events.ts
16877
- const TASK_EVENT_DEBOUNCE_MS = 1e3;
16878
- function turnNumber(value) {
16879
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
16880
- }
16881
- function turnData(value) {
16882
- if (value === null || typeof value !== "object" || Array.isArray(value)) return {
16883
- turn: 0,
16884
- completed: false
16885
- };
16886
- const record = value;
16887
- const reason = record.reason;
16888
- const completed = typeof reason === "string" ? reason === "completed" : reason !== null && typeof reason === "object" && reason.kind === "completed";
16889
- return {
16890
- turn: turnNumber(record.turn),
16891
- completed
16892
- };
16893
- }
16894
- /**
16895
- * Subscribe to completed root turns. Subagent turns are skipped so one task
16896
- * announces once, and rapid turn boundaries collapse into a single event.
16897
- * Returns a disposer that also drops pending debounces.
16898
- */
16899
- function watchTaskCompletions(ctx, options) {
16900
- const debounceMs = options.debounceMs ?? 1e3;
16901
- const pending = /* @__PURE__ */ new Map();
16902
- const disposeListener = ctx.on("session/event", (session, event) => {
16903
- if (event.type !== "turn/end") return;
16904
- const { turn, completed } = turnData(event.data);
16905
- if (!completed) return;
16906
- if (session.header?.parentSession !== void 0 && session.header?.parentSession !== null) return;
16907
- const sessionId = String(session.id);
16908
- options.log?.("task-completed-observed", {
16909
- sessionId,
16910
- turn
16911
- });
16912
- const previous = pending.get(sessionId);
16913
- if (previous !== void 0) clearTimeout(previous);
16914
- pending.set(sessionId, setTimeout(() => {
16915
- pending.delete(sessionId);
16916
- options.log?.("task-completed-announced", {
16917
- sessionId,
16918
- turn
16919
- });
16920
- options.onTaskCompleted(Object.freeze({
16921
- sessionId,
16922
- turn
16923
- }));
16924
- }, debounceMs));
16925
- });
16926
- return () => {
16927
- disposeListener();
16928
- for (const timer of pending.values()) clearTimeout(timer);
16929
- pending.clear();
16930
- };
16931
- }
16932
- /** One subscription feeding every live gateway; gateways register on start. */
16933
- var TaskEventHub = class {
16934
- sinks = /* @__PURE__ */ new Set();
16935
- /** Register a sink; returns its disposer. */
16936
- add(sink) {
16937
- this.sinks.add(sink);
16938
- return () => {
16939
- this.sinks.delete(sink);
16940
- };
16941
- }
16942
- /** Fan out to a snapshot so a failing sink cannot break its siblings. */
16943
- broadcast(event) {
16944
- for (const sink of [...this.sinks]) try {
16945
- sink.broadcastTaskEvent(event);
16946
- } catch {}
16947
- }
16948
- /** Visible for tests. */
16949
- get size() {
16950
- return this.sinks.size;
16951
- }
16952
- };
16953
17111
  // src/diagnostic-network-context.ts
16954
17112
  function addressKind(address) {
16955
17113
  if (isIP(address) === 4) {
@@ -21306,6 +21464,74 @@ const OUTCOME_COALESCE_MS = 1e3;
21306
21464
  const ATTENTION_TTL_MS = 3e5;
21307
21465
  const OUTCOME_TTL_MS = 18e5;
21308
21466
  const ATTEMPT_TIMEOUT_MS = 1e4;
21467
+ // src/mobile-push/ownership.ts
21468
+ const MAX_OWNERSHIP_DEPTH = 64;
21469
+ /**
21470
+ * Read the durable header from the object a public event delivered.
21471
+ *
21472
+ * A `session/event` observer receives the Session itself, whose header lives on
21473
+ * the live Agent; a request can carry an Agent-shaped identity without the
21474
+ * Session attached. Resolve both through the public registry instead of
21475
+ * assuming one shape.
21476
+ */
21477
+ function durableHeader(registry, agent) {
21478
+ const direct = agent.session?.header;
21479
+ if (direct !== void 0) return direct;
21480
+ return registry?.get?.(agent.id)?.session?.header;
21481
+ }
21482
+ /**
21483
+ * Find the owning parent recorded by the durable Session header.
21484
+ *
21485
+ * Only a header that explicitly classifies the Session as a subagent child
21486
+ * creates ownership. Historical ordinary forks may retain a `parentSession`
21487
+ * source id, so the field alone is not evidence of ownership.
21488
+ */
21489
+ function owningParentId(registry, agent) {
21490
+ const header = durableHeader(registry, agent);
21491
+ if (header?.origin !== "subagent") return void 0;
21492
+ const parent = header.parentSession;
21493
+ return typeof parent === "string" && parent.length > 0 ? parent : void 0;
21494
+ }
21495
+ /** The public ownership walk available before DSH 0.2.2-rc.2, when it exists. */
21496
+ function legacyOwnerOf(registry) {
21497
+ if (typeof registry?.list !== "function" || typeof registry.isOwnedBy !== "function") return void 0;
21498
+ return (id) => (registry?.list?.() ?? []).find((candidate) => registry?.isOwnedBy?.(id, candidate) === true);
21499
+ }
21500
+ /**
21501
+ * Whether this Agent is a runtime child that must not notify on its own.
21502
+ *
21503
+ * Without the removed registry ownership API, the live parent Agent recorded
21504
+ * by the header is the public evidence; an orphan does not count as owned,
21505
+ * matching the earlier walk that stopped at a missing owner.
21506
+ */
21507
+ function isPushRuntimeChild(registry, agent) {
21508
+ const ownerOf = legacyOwnerOf(registry);
21509
+ if (ownerOf !== void 0) return ownerOf(agent.id) !== void 0;
21510
+ const parentId = owningParentId(registry, agent);
21511
+ return parentId !== void 0 && registry?.get?.(parentId) !== void 0;
21512
+ }
21513
+ /**
21514
+ * Resolve the root Session a notification should target.
21515
+ *
21516
+ * Uses the removed `isOwnedBy` walk when the host still exposes it, and the
21517
+ * durable subagent header ancestry otherwise. A child whose parent Agent is no
21518
+ * longer live remains its own root, matching the previous behavior.
21519
+ */
21520
+ function pushRootAgent(registry, agent) {
21521
+ const ownerOf = legacyOwnerOf(registry);
21522
+ let current = agent;
21523
+ for (let count = 0; count < MAX_OWNERSHIP_DEPTH; count++) {
21524
+ let parent;
21525
+ if (ownerOf !== void 0) parent = ownerOf(current.id);
21526
+ else {
21527
+ const parentId = owningParentId(registry, current);
21528
+ parent = parentId === void 0 ? void 0 : registry?.get?.(parentId);
21529
+ }
21530
+ if (parent === void 0 || parent.id === current.id) return current;
21531
+ current = parent;
21532
+ }
21533
+ return current;
21534
+ }
21309
21535
  // src/mobile-push/requests.ts
21310
21536
  var RequestLifetimes = class {
21311
21537
  #records = /* @__PURE__ */ new Map();
@@ -21467,15 +21693,7 @@ var RequestLifetimes = class {
21467
21693
  return rec;
21468
21694
  }
21469
21695
  #root(agent) {
21470
- const registry = this.#ctx.agents;
21471
- if (!registry?.isOwnedBy || !registry.get || !registry.list) return agent;
21472
- let current = agent;
21473
- for (let count = 0; count < 64; count++) {
21474
- const parent = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
21475
- if (!parent) break;
21476
- current = parent;
21477
- }
21478
- return current;
21696
+ return pushRootAgent(this.#ctx.agents, agent);
21479
21697
  }
21480
21698
  #arm(rec) {
21481
21699
  if (rec.timer) return;
@@ -21650,20 +21868,10 @@ var OutcomeLifetimes = class {
21650
21868
  }
21651
21869
  }
21652
21870
  #isRuntimeChild(agent) {
21653
- const registry = this.#ctx.agents;
21654
- if (!registry?.isOwnedBy || !registry.list) return false;
21655
- return registry.list().some((owner) => registry.isOwnedBy?.(agent.id, owner));
21871
+ return isPushRuntimeChild(this.#ctx.agents, agent);
21656
21872
  }
21657
21873
  #root(agent) {
21658
- const registry = this.#ctx.agents;
21659
- if (!registry?.isOwnedBy || !registry.list) return agent;
21660
- let current = agent;
21661
- for (let count = 0; count < 64; count++) {
21662
- const owner = registry.list().find((candidate) => registry.isOwnedBy?.(current.id, candidate));
21663
- if (!owner) return current;
21664
- current = owner;
21665
- }
21666
- return current;
21874
+ return pushRootAgent(this.#ctx.agents, agent);
21667
21875
  }
21668
21876
  };
21669
21877
  // src/mobile-push/reconcile.ts
@@ -21940,7 +22148,7 @@ var PushReconciler = class {
21940
22148
  };
21941
22149
  // src/mobile-push/host.ts
21942
22150
  function hasContract(ctx) {
21943
- 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;
22151
+ 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;
21944
22152
  }
21945
22153
  const MAINTENANCE_INTERVAL_MS = 3e4;
21946
22154
  function createPushHost(ctx) {
@@ -24096,6 +24304,7 @@ var PushDelivery = class {
24096
24304
  if (this.#closed) return;
24097
24305
  if (this.#wakeTimer) clearTimeout(this.#wakeTimer);
24098
24306
  this.#wakeTimer = void 0;
24307
+ if (this.#active >= 4) return;
24099
24308
  this.#pruneDeferred();
24100
24309
  let nextAt = Number.POSITIVE_INFINITY;
24101
24310
  try {
@@ -24874,6 +25083,9 @@ async function apply(ctx, config) {
24874
25083
  const taskEventHub = new TaskEventHub();
24875
25084
  const disposeTaskEvents = watchTaskCompletions(ctx, {
24876
25085
  onTaskCompleted: (event) => taskEventHub.broadcast(event),
25086
+ readSessionTitle: (session) => {
25087
+ return (ctx.get?.("sessionProjections"))?.stateOf?.(session, "title");
25088
+ },
24877
25089
  log(event, fields) {
24878
25090
  logger.info("task event=%s fields=%o", event, fields);
24879
25091
  }