@gajae-code/coding-agent 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/types/config/autorouting-tier-map.d.ts +0 -9
  3. package/dist/types/config/file-lock.d.ts +7 -1
  4. package/dist/types/config/keybindings.d.ts +13 -3
  5. package/dist/types/config/model-registry.d.ts +5 -0
  6. package/dist/types/config/model-resolver.d.ts +1 -0
  7. package/dist/types/config/settings-schema.d.ts +65 -1
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/question-state.d.ts +42 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  11. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  12. package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
  13. package/dist/types/main.d.ts +1 -1
  14. package/dist/types/modes/action-registry.d.ts +1 -0
  15. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  16. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -1
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +16 -1
  19. package/dist/types/modes/controllers/runtime-mcp-command-controller.d.ts +42 -0
  20. package/dist/types/modes/interactive-mode.d.ts +7 -0
  21. package/dist/types/modes/shared/oauth-url-copy.d.ts +9 -0
  22. package/dist/types/modes/types.d.ts +3 -0
  23. package/dist/types/modes/ui-language.d.ts +2 -1
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  25. package/dist/types/sdk/broker/session-index.d.ts +18 -0
  26. package/dist/types/sdk/bus/index.d.ts +4 -1
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  29. package/dist/types/sdk/cli/session-cli.d.ts +1 -1
  30. package/dist/types/sdk/lifecycle/service.d.ts +32 -5
  31. package/dist/types/sdk/session.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +4 -0
  33. package/dist/types/session/fallback-chain-controller.d.ts +8 -0
  34. package/dist/types/slash-commands/helpers/aside.d.ts +54 -0
  35. package/dist/types/tools/ask.d.ts +9 -0
  36. package/dist/types/utils/clipboard.d.ts +9 -1
  37. package/package.json +7 -7
  38. package/scripts/generate-sdk-adapter-parity-manifest.ts +5 -2
  39. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  40. package/src/commands/sdk.ts +2 -2
  41. package/src/config/autorouting-tier-map.ts +0 -9
  42. package/src/config/file-lock.ts +15 -1
  43. package/src/config/keybindings.ts +13 -3
  44. package/src/config/model-registry.ts +132 -21
  45. package/src/config/model-resolver.ts +2 -1
  46. package/src/config/models-config-schema.ts +2 -2
  47. package/src/config/settings-schema.ts +66 -1
  48. package/src/config/settings.ts +1 -1
  49. package/src/coordinator/contract.ts +1 -0
  50. package/src/coordinator-mcp/question-state.ts +235 -4
  51. package/src/coordinator-mcp/server.ts +402 -2
  52. package/src/dap/client.ts +19 -8
  53. package/src/deep-interview/render-middleware.ts +6 -2
  54. package/src/extensibility/extensions/types.ts +2 -0
  55. package/src/gjc-runtime/deep-interview-stage.ts +20 -1
  56. package/src/gjc-runtime/deep-interview-state.ts +3 -1
  57. package/src/gjc-runtime/ralplan-runtime.ts +6 -1
  58. package/src/gjc-runtime/session-state-lock.ts +11 -2
  59. package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
  60. package/src/gjc-runtime/workflow-placeholder.ts +33 -0
  61. package/src/internal-urls/docs-index.generated.ts +11 -9
  62. package/src/lsp/client.ts +39 -4
  63. package/src/main.ts +42 -11
  64. package/src/modes/action-registry.ts +7 -0
  65. package/src/modes/components/hook-selector.ts +2 -1
  66. package/src/modes/components/iterm-pet-transport.ts +4 -2
  67. package/src/modes/components/runtime-mcp-add-wizard.ts +38 -1
  68. package/src/modes/components/settings-selector.ts +1 -0
  69. package/src/modes/components/tool-status-header.ts +149 -41
  70. package/src/modes/components/welcome.ts +8 -0
  71. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  72. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  73. package/src/modes/controllers/input-controller.ts +141 -7
  74. package/src/modes/controllers/plan-mode-controller.ts +12 -4
  75. package/src/modes/controllers/runtime-mcp-command-controller.ts +164 -20
  76. package/src/modes/controllers/selector-controller.ts +11 -2
  77. package/src/modes/interactive-mode.ts +88 -1
  78. package/src/modes/shared/oauth-url-copy.ts +33 -0
  79. package/src/modes/types.ts +3 -0
  80. package/src/modes/ui-language.ts +44 -3
  81. package/src/runtime-mcp/oauth-flow.ts +19 -8
  82. package/src/runtime-mcp/transports/stdio.ts +27 -13
  83. package/src/sdk/acp/adapter.ts +29 -2
  84. package/src/sdk/broker/broker.ts +26 -6
  85. package/src/sdk/broker/lifecycle-ledger.ts +28 -1
  86. package/src/sdk/broker/lifecycle.ts +664 -5
  87. package/src/sdk/broker/session-index.ts +246 -0
  88. package/src/sdk/broker/transport.ts +1 -0
  89. package/src/sdk/bus/index.ts +135 -38
  90. package/src/sdk/bus/natives-augment.d.ts +20 -1
  91. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  92. package/src/sdk/bus/telegram-daemon.ts +3 -0
  93. package/src/sdk/cli/session-cli.ts +34 -7
  94. package/src/sdk/lifecycle/service.ts +166 -11
  95. package/src/sdk/mcp/server.ts +2 -1
  96. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  97. package/src/sdk/protocol/operation-registry.ts +2 -1
  98. package/src/sdk/session.ts +91 -7
  99. package/src/session/agent-session.ts +473 -66
  100. package/src/session/fallback-chain-controller.ts +17 -0
  101. package/src/setup/frictionless-onboarding.ts +4 -4
  102. package/src/slash-commands/aside-command.test.ts +366 -0
  103. package/src/slash-commands/builtin-registry.ts +19 -0
  104. package/src/slash-commands/helpers/aside.ts +299 -0
  105. package/src/tools/ask-contract.ts +54 -3
  106. package/src/tools/ask.ts +54 -6
  107. package/src/utils/clipboard.ts +37 -28
@@ -915,6 +915,91 @@ interface SessionIndexOpenGroup {
915
915
  promise: Promise<void>;
916
916
  closed: boolean;
917
917
  }
918
+
919
+ type SessionIdentityExpectation = {
920
+ sessionId: string;
921
+ stateRoot: string;
922
+ endpointGeneration: number;
923
+ pid: number;
924
+ processIncarnation?: string;
925
+ hostIncarnation?: string;
926
+ endpointMtimeMs?: number;
927
+ lifecycleRequestId?: string;
928
+ };
929
+
930
+ function sameSessionTuple(
931
+ event: SessionIndexEvent,
932
+ expected: SessionIdentityExpectation,
933
+ expectedRoot: string,
934
+ ): boolean {
935
+ return (
936
+ event.sessionId === expected.sessionId &&
937
+ event.endpointGeneration === expected.endpointGeneration &&
938
+ resolveEquivalentPath(event.locator.stateRoot) === expectedRoot
939
+ );
940
+ }
941
+
942
+ function sameSessionIdentity(
943
+ event: SessionIndexEvent,
944
+ expected: SessionIdentityExpectation,
945
+ expectedRoot: string,
946
+ ): boolean {
947
+ return (
948
+ sameSessionTuple(event, expected, expectedRoot) &&
949
+ event.pid === expected.pid &&
950
+ event.processIncarnation === expected.processIncarnation &&
951
+ event.hostIncarnation === expected.hostIncarnation &&
952
+ event.endpointMtimeMs === expected.endpointMtimeMs &&
953
+ event.lifecycleRequestId === expected.lifecycleRequestId
954
+ );
955
+ }
956
+
957
+ function supersededAtForIdentity(
958
+ events: readonly SessionIndexEvent[],
959
+ expected: SessionIdentityExpectation,
960
+ ): number | undefined {
961
+ const expectedRoot = resolveEquivalentPath(expected.stateRoot);
962
+ const targetRegistration = events.findLast(
963
+ event => event.type === "host_registered" && sameSessionIdentity(event, expected, expectedRoot),
964
+ );
965
+ const targetAnchor =
966
+ targetRegistration?.indexSeq ??
967
+ events.find(event => sameSessionIdentity(event, expected, expectedRoot))?.indexSeq;
968
+ if (targetAnchor === undefined) return undefined;
969
+ return events
970
+ .filter(
971
+ event =>
972
+ event.type === "host_registered" &&
973
+ sameSessionTuple(event, expected, expectedRoot) &&
974
+ !sameSessionIdentity(event, expected, expectedRoot) &&
975
+ event.indexSeq > targetAnchor,
976
+ )
977
+ .map(event => event.indexSeq)
978
+ .sort((a, b) => a - b)[0];
979
+ }
980
+
981
+ function followsApplicableTombstone(
982
+ events: readonly SessionIndexEvent[],
983
+ candidate: SessionIndexEvent,
984
+ expected: SessionIdentityExpectation,
985
+ expectedRoot: string,
986
+ ): boolean {
987
+ const tombstone = events.findLast(
988
+ event =>
989
+ event.type === "session_deleted" &&
990
+ event.indexSeq < candidate.indexSeq &&
991
+ sameSessionIdentity(event, expected, expectedRoot),
992
+ );
993
+ if (tombstone === undefined) return false;
994
+ const reRegistration = events.findLast(
995
+ event =>
996
+ event.type === "host_registered" &&
997
+ event.indexSeq < candidate.indexSeq &&
998
+ sameSessionIdentity(event, expected, expectedRoot),
999
+ );
1000
+ return reRegistration === undefined || reRegistration.indexSeq < tombstone.indexSeq;
1001
+ }
1002
+
918
1003
  export class SessionIndex {
919
1004
  static #operations = new Map<string, Promise<void>>();
920
1005
  static #openGroups = new Map<string, SessionIndexOpenGroup>();
@@ -1774,6 +1859,167 @@ export class SessionIndex {
1774
1859
  return reduceEvents(this.#events, this.#policy.clock(), this.#agentDir, probedIncarnations).identities;
1775
1860
  }
1776
1861
 
1862
+ /** Reconstructs a receipt-bound identity even when admission superseded it. */
1863
+ findHistoricalSessionIdentity(expected: {
1864
+ sessionId: string;
1865
+ stateRoot: string;
1866
+ endpointGeneration: number;
1867
+ pid: number;
1868
+ processIncarnation?: string;
1869
+ hostIncarnation?: string;
1870
+ endpointMtimeMs?: number;
1871
+ lifecycleRequestId?: string;
1872
+ }): IndexedSession | undefined {
1873
+ const expectedRoot = resolveEquivalentPath(expected.stateRoot);
1874
+ const supersededAt = supersededAtForIdentity(this.#events, expected);
1875
+ const matching = this.#events.filter(
1876
+ event =>
1877
+ (supersededAt === undefined || event.indexSeq < supersededAt) &&
1878
+ sameSessionIdentity(event, expected, expectedRoot),
1879
+ );
1880
+ const latest = matching.findLast(event => event.type !== "host_heartbeat");
1881
+ if (latest === undefined) return undefined;
1882
+ return projectIdentity(
1883
+ {
1884
+ identity: identityKey(latest),
1885
+ latest,
1886
+ heartbeat: matching.findLast(event => event.type === "host_heartbeat"),
1887
+ },
1888
+ false,
1889
+ this.#policy.clock(),
1890
+ );
1891
+ }
1892
+
1893
+ /** Returns the latest identity-bound terminal event, if retained. */
1894
+ findSessionTerminalEvidence(
1895
+ expected: Pick<
1896
+ IndexedSession,
1897
+ | "sessionId"
1898
+ | "locator"
1899
+ | "endpointGeneration"
1900
+ | "pid"
1901
+ | "processIncarnation"
1902
+ | "hostIncarnation"
1903
+ | "endpointMtimeMs"
1904
+ | "lifecycleRequestId"
1905
+ >,
1906
+ ): { type: "host_unregistered" | "session_closed" | "session_deleted"; indexSeq: number } | undefined {
1907
+ const expectedRoot = resolveEquivalentPath(expected.locator.stateRoot);
1908
+ const expectedIncarnation = expected.hostIncarnation ?? expected.processIncarnation;
1909
+ const supersededAt = supersededAtForIdentity(this.#events, {
1910
+ sessionId: expected.sessionId,
1911
+ stateRoot: expected.locator.stateRoot,
1912
+ endpointGeneration: expected.endpointGeneration,
1913
+ pid: expected.pid,
1914
+ processIncarnation: expected.processIncarnation,
1915
+ hostIncarnation: expected.hostIncarnation,
1916
+ endpointMtimeMs: expected.endpointMtimeMs,
1917
+ lifecycleRequestId: expected.lifecycleRequestId,
1918
+ });
1919
+ const event = this.#events.findLast(
1920
+ event =>
1921
+ (event.type === "host_unregistered" ||
1922
+ event.type === "session_closed" ||
1923
+ event.type === "session_deleted") &&
1924
+ (event.hostIncarnation ?? event.processIncarnation) === expectedIncarnation &&
1925
+ (supersededAt === undefined || event.indexSeq < supersededAt) &&
1926
+ !followsApplicableTombstone(
1927
+ this.#events,
1928
+ event,
1929
+ {
1930
+ sessionId: expected.sessionId,
1931
+ stateRoot: expected.locator.stateRoot,
1932
+ endpointGeneration: expected.endpointGeneration,
1933
+ pid: expected.pid,
1934
+ processIncarnation: expected.processIncarnation,
1935
+ hostIncarnation: expected.hostIncarnation,
1936
+ endpointMtimeMs: expected.endpointMtimeMs,
1937
+ lifecycleRequestId: expected.lifecycleRequestId,
1938
+ },
1939
+ expectedRoot,
1940
+ ) &&
1941
+ sameSessionIdentity(
1942
+ event,
1943
+ {
1944
+ sessionId: expected.sessionId,
1945
+ stateRoot: expected.locator.stateRoot,
1946
+ endpointGeneration: expected.endpointGeneration,
1947
+ pid: expected.pid,
1948
+ processIncarnation: expected.processIncarnation,
1949
+ hostIncarnation: expected.hostIncarnation,
1950
+ endpointMtimeMs: expected.endpointMtimeMs,
1951
+ lifecycleRequestId: expected.lifecycleRequestId,
1952
+ },
1953
+ expectedRoot,
1954
+ ),
1955
+ );
1956
+ return event &&
1957
+ (event.type === "host_unregistered" || event.type === "session_closed" || event.type === "session_deleted")
1958
+ ? { type: event.type, indexSeq: event.indexSeq }
1959
+ : undefined;
1960
+ }
1961
+
1962
+ /** Returns the latest identity-bound session_closed evidence, even after a tombstone. */
1963
+ findSessionClosedEvidence(
1964
+ expected: Pick<
1965
+ IndexedSession,
1966
+ | "sessionId"
1967
+ | "locator"
1968
+ | "endpointGeneration"
1969
+ | "pid"
1970
+ | "processIncarnation"
1971
+ | "hostIncarnation"
1972
+ | "endpointMtimeMs"
1973
+ | "lifecycleRequestId"
1974
+ >,
1975
+ ): number | undefined {
1976
+ const expectedRoot = resolveEquivalentPath(expected.locator.stateRoot);
1977
+ const supersededAt = supersededAtForIdentity(this.#events, {
1978
+ sessionId: expected.sessionId,
1979
+ stateRoot: expected.locator.stateRoot,
1980
+ endpointGeneration: expected.endpointGeneration,
1981
+ pid: expected.pid,
1982
+ processIncarnation: expected.processIncarnation,
1983
+ hostIncarnation: expected.hostIncarnation,
1984
+ endpointMtimeMs: expected.endpointMtimeMs,
1985
+ lifecycleRequestId: expected.lifecycleRequestId,
1986
+ });
1987
+ return this.#events.findLast(
1988
+ event =>
1989
+ event.type === "session_closed" &&
1990
+ (supersededAt === undefined || event.indexSeq < supersededAt) &&
1991
+ !followsApplicableTombstone(
1992
+ this.#events,
1993
+ event,
1994
+ {
1995
+ sessionId: expected.sessionId,
1996
+ stateRoot: expected.locator.stateRoot,
1997
+ endpointGeneration: expected.endpointGeneration,
1998
+ pid: expected.pid,
1999
+ processIncarnation: expected.processIncarnation,
2000
+ hostIncarnation: expected.hostIncarnation,
2001
+ endpointMtimeMs: expected.endpointMtimeMs,
2002
+ lifecycleRequestId: expected.lifecycleRequestId,
2003
+ },
2004
+ expectedRoot,
2005
+ ) &&
2006
+ sameSessionIdentity(
2007
+ event,
2008
+ {
2009
+ sessionId: expected.sessionId,
2010
+ stateRoot: expected.locator.stateRoot,
2011
+ endpointGeneration: expected.endpointGeneration,
2012
+ pid: expected.pid,
2013
+ processIncarnation: expected.processIncarnation,
2014
+ hostIncarnation: expected.hostIncarnation,
2015
+ endpointMtimeMs: expected.endpointMtimeMs,
2016
+ lifecycleRequestId: expected.lifecycleRequestId,
2017
+ },
2018
+ expectedRoot,
2019
+ ),
2020
+ )?.indexSeq;
2021
+ }
2022
+
1777
2023
  /**
1778
2024
  * Production coalesced heartbeat checkpoint pass (C2): appends one
1779
2025
  * `host_heartbeat` per session at most once per {@link SESSION_HEARTBEAT_INTERVAL_MS}.
@@ -14,6 +14,7 @@ const BROKER_OPERATIONS = new Set([
14
14
  "session.resume",
15
15
  "session.close",
16
16
  "session.delete",
17
+ "session.reconcile_uncertain",
17
18
  "broker.shutdown",
18
19
  "session.control",
19
20
  "broker.lookup_lifecycle",
@@ -166,6 +166,7 @@ import {
166
166
  ASK_SELECTED_ACK_CAPABILITY,
167
167
  type EnsureDaemonResult,
168
168
  ensureTelegramDaemonRunningDetailed,
169
+ POSITIONED_NOTIFICATION_EFFECTS_CAPABILITY,
169
170
  } from "./telegram-daemon";
170
171
 
171
172
  export type {
@@ -1176,7 +1177,10 @@ interface SessionRuntime {
1176
1177
  host: SessionSdkHost;
1177
1178
  /** Delivers one ring-positioned event envelope to every attached subscriber
1178
1179
  * connection, applying the same capability gate as event replay. */
1179
- broadcastEventFrame: (event: SdkFrame) => void;
1180
+ broadcastEventFrame: (event: SdkFrame) => string[];
1181
+ /** Delivers one positioned event and returns opaque receipts only for the
1182
+ * notification-effect generations that accepted it. */
1183
+ broadcastEventFrameWithReceipts: (event: SdkFrame) => string[];
1180
1184
  /** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */
1181
1185
  revisions: RevisionStore;
1182
1186
  /** Releases all snapshot pins before the revision store is closed. */
@@ -1403,26 +1407,39 @@ function emitSessionEvent(
1403
1407
  runtime: Pick<SessionRuntime, "host" | "broadcastEventFrame">,
1404
1408
  frame: { type: string; [key: string]: unknown },
1405
1409
  payload: Record<string, unknown> = frame,
1406
- ): void {
1407
- runtime.broadcastEventFrame(runtime.host.emitEvent({ kind: frame.type, payload }));
1410
+ ): string[] {
1411
+ return runtime.broadcastEventFrame(runtime.host.emitEvent({ kind: frame.type, payload }));
1412
+ }
1413
+
1414
+ function emitSessionEventWithReceipts(
1415
+ runtime: Pick<SessionRuntime, "host" | "broadcastEventFrameWithReceipts">,
1416
+ frame: { type: string; [key: string]: unknown },
1417
+ ): string[] {
1418
+ return runtime.broadcastEventFrameWithReceipts(runtime.host.emitEvent({ kind: frame.type, payload: frame }));
1408
1419
  }
1409
1420
 
1410
1421
  function pushSessionFrame(
1411
1422
  runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
1412
1423
  frame: { type: string; [key: string]: unknown },
1413
- ): void {
1414
- emitSessionEvent(runtime, frame);
1424
+ ): boolean {
1425
+ const positionedRecipients = emitSessionEvent(runtime, frame);
1415
1426
  if (frame.type === "turn_stream") {
1416
- runtime.server.pushTurnStreamUnchecked(
1427
+ const rawAccepted = runtime.server.pushTurnStreamUnchecked(
1417
1428
  String(frame.sessionId),
1418
1429
  frame.phase === "live" ? "live" : "finalized",
1419
1430
  String(frame.text),
1420
1431
  typeof frame.finalAnswer === "boolean" ? frame.finalAnswer : undefined,
1421
1432
  typeof frame.messageRef === "string" ? frame.messageRef : undefined,
1433
+ positionedRecipients,
1422
1434
  );
1423
- return;
1435
+ // A retained lean settlement may be consumed only after either the
1436
+ // positioned or legacy raw transport accepts the lead-in. Older native addons
1437
+ // return undefined here, which deliberately fails closed unless the
1438
+ // positioned leg has already proved acceptance.
1439
+ return positionedRecipients.length > 0 || rawAccepted === true;
1424
1440
  }
1425
- runtime.server.pushFrame(JSON.stringify(frame));
1441
+ runtime.server.pushFrame(JSON.stringify(frame), positionedRecipients);
1442
+ return positionedRecipients.length > 0;
1426
1443
  }
1427
1444
 
1428
1445
  async function pushTerminalSessionFrame(
@@ -1430,6 +1447,9 @@ async function pushTerminalSessionFrame(
1430
1447
  frame: { type: "session_closed"; sessionId: string },
1431
1448
  ): Promise<boolean> {
1432
1449
  emitSessionEvent(runtime, frame);
1450
+ // Terminal shutdown still uses the acknowledged native leg: the positioned
1451
+ // send is best-effort and the server must not stop before a socket writer has
1452
+ // settled this final frame.
1433
1453
  return await runtime.server.pushFrameAndWait(JSON.stringify(frame), 1_000);
1434
1454
  }
1435
1455
 
@@ -1438,8 +1458,15 @@ function pushFileAttachment(
1438
1458
  frame: { type: "file_attachment"; sessionId: string; name: string; mime?: string; caption?: string },
1439
1459
  data: Buffer,
1440
1460
  ): void {
1441
- emitSessionEvent(runtime, frame, { ...frame, data: data.toString("base64") });
1442
- runtime.server.pushFileAttachmentUnchecked(frame.sessionId, frame.name, frame.mime, data, frame.caption);
1461
+ const positionedRecipients = emitSessionEvent(runtime, frame, { ...frame, data: data.toString("base64") });
1462
+ runtime.server.pushFileAttachmentUnchecked(
1463
+ frame.sessionId,
1464
+ frame.name,
1465
+ frame.mime,
1466
+ data,
1467
+ frame.caption,
1468
+ positionedRecipients,
1469
+ );
1443
1470
  }
1444
1471
 
1445
1472
  /** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
@@ -1449,8 +1476,8 @@ function emitAgentLifecycle(
1449
1476
  ): void {
1450
1477
  try {
1451
1478
  const json = JSON.stringify(frame);
1452
- emitSessionEvent(runtime, frame);
1453
- runtime.server.pushFrame(json);
1479
+ const positionedRecipients = emitSessionEvent(runtime, frame);
1480
+ runtime.server.pushFrame(json, positionedRecipients);
1454
1481
  } catch (error) {
1455
1482
  logger.warn(`sdk: lifecycle delivery failed: ${String(error)}`);
1456
1483
  }
@@ -4743,20 +4770,41 @@ export function createNotificationsExtension(
4743
4770
  * or an event replay, which is exactly the attached-subscriber set. Fenced
4744
4771
  * connections are excluded like their inbound frames, and capability-gated
4745
4772
  * kinds follow the same gate replay applies, so live and replay delivery
4746
- * stay one truth per connection.
4773
+ * stay one truth per connection. Only notification adapters that explicitly
4774
+ * negotiate positioned-only effects are returned for matching raw exclusion;
4775
+ * ordinary direct SDK subscribers retain both public surfaces from #4570.
4747
4776
  */
4748
- const broadcastEventFrame = (event: SdkFrame): void => {
4777
+ const broadcastEventFrame = (event: SdkFrame): string[] => {
4749
4778
  const gated = CAP_GATED_FRAME_KINDS.has(String(event.kind));
4750
4779
  const json = JSON.stringify(event);
4780
+ const recipients: string[] = [];
4751
4781
  for (const [connectionId, capabilities] of hostCapCache) {
4752
4782
  if (fencedConnections.has(connectionId)) continue;
4753
4783
  if (gated && !capabilities.has(TOOL_ACTIVITY_CAPABILITY)) continue;
4754
4784
  try {
4755
4785
  server.sendTo(connectionId, json);
4786
+ if (capabilities.has(POSITIONED_NOTIFICATION_EFFECTS_CAPABILITY)) recipients.push(connectionId);
4756
4787
  } catch {
4757
4788
  // Broadcasts are best effort; directed responses surface send failures.
4758
4789
  }
4759
4790
  }
4791
+ return recipients;
4792
+ };
4793
+ const broadcastEventFrameWithReceipts = (event: SdkFrame): string[] => {
4794
+ const gated = CAP_GATED_FRAME_KINDS.has(String(event.kind));
4795
+ const json = JSON.stringify(event);
4796
+ const receipts: string[] = [];
4797
+ for (const [connectionId, capabilities] of hostCapCache) {
4798
+ if (fencedConnections.has(connectionId)) continue;
4799
+ if (gated && !capabilities.has(TOOL_ACTIVITY_CAPABILITY)) continue;
4800
+ try {
4801
+ const receipt = server.sendToWithReceipt(connectionId, json);
4802
+ if (capabilities.has(POSITIONED_NOTIFICATION_EFFECTS_CAPABILITY)) receipts.push(receipt);
4803
+ } catch {
4804
+ // Rejected positioned sends remain eligible for the atomic raw fallback.
4805
+ }
4806
+ }
4807
+ return receipts;
4760
4808
  };
4761
4809
  let cancelPreflightsForConnection: ((connectionId: string) => Promise<void>) | undefined;
4762
4810
  const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
@@ -6836,6 +6884,7 @@ export function createNotificationsExtension(
6836
6884
  server,
6837
6885
  host,
6838
6886
  broadcastEventFrame,
6887
+ broadcastEventFrameWithReceipts,
6839
6888
  revisions,
6840
6889
  cursors,
6841
6890
  id,
@@ -7109,6 +7158,19 @@ export function createNotificationsExtension(
7109
7158
  "@gajae-code/natives is out of date: missing onNegotiatedCapabilities. Rebuild the native addon (bun --cwd=packages/natives run build).",
7110
7159
  );
7111
7160
  }
7161
+ if (
7162
+ typeof server.supportsPositionedRawExclusion !== "function" ||
7163
+ server.supportsPositionedRawExclusion() !== true
7164
+ ) {
7165
+ throw new Error(
7166
+ "@gajae-code/natives is out of date: missing positioned raw-fan-out exclusion. Rebuild the native addon (bun --cwd=packages/natives run build).",
7167
+ );
7168
+ }
7169
+ if (typeof server.sendToWithReceipt !== "function" || typeof server.queueIdleAfterDirected !== "function") {
7170
+ throw new Error(
7171
+ "@gajae-code/natives is out of date: missing recipient-bound dependent delivery. Rebuild the native addon (bun --cwd=packages/natives run build).",
7172
+ );
7173
+ }
7112
7174
  server.onNegotiatedCapabilities((_err, connectionId, capabilities) => {
7113
7175
  if (connectionId) hostCapCache.set(connectionId, new Set(capabilities));
7114
7176
  });
@@ -7670,8 +7732,8 @@ export function createNotificationsExtension(
7670
7732
  sessionId: id,
7671
7733
  ...buildIdentity(ctx.cwd, sessionName, telegramTopicsEnabled()),
7672
7734
  };
7673
- emitSessionEvent(initializedRuntime, identity);
7674
- server.pushFrame(JSON.stringify(identity));
7735
+ const positionedRecipients = emitSessionEvent(initializedRuntime, identity);
7736
+ server.pushFrame(JSON.stringify(identity), positionedRecipients);
7675
7737
  }, 250);
7676
7738
  sessionNameObserver.unref?.();
7677
7739
  runtime.stopSessionNameObserver = () => clearInterval(sessionNameObserver);
@@ -7684,8 +7746,8 @@ export function createNotificationsExtension(
7684
7746
  sessionId: id,
7685
7747
  ...buildIdentity(ctx.cwd, sessionNameAfterStartup, telegramTopicsEnabled()),
7686
7748
  };
7687
- emitSessionEvent(initializedRuntime, identity);
7688
- server.pushFrame(JSON.stringify(identity));
7749
+ const positionedRecipients = emitSessionEvent(initializedRuntime, identity);
7750
+ server.pushFrame(JSON.stringify(identity), positionedRecipients);
7689
7751
  }
7690
7752
  const agentDir = lifecycleAgentDir ?? settings?.getAgentDir?.();
7691
7753
  if (lifecycleRequired && !agentDir) throw new Error("Lifecycle SDK host requires an agent directory.");
@@ -8463,6 +8525,20 @@ export function createNotificationsExtension(
8463
8525
  };
8464
8526
 
8465
8527
  const hasUserSettlementReceipt = (rt: SessionRuntime): boolean => rt.pendingSettled?.receipts[0]?.origin === "user";
8528
+ const composeSettlementText = (receipts: ReadonlyArray<{ text: string }>): string =>
8529
+ receipts.map(receipt => receipt.text).join("\n\n");
8530
+
8531
+ const consumePublishedSettlement = (rt: SessionRuntime, text: string, window: number): void => {
8532
+ const pending = rt.pendingSettled;
8533
+ if (!pending || pending.window !== window) return;
8534
+ if (composeSettlementText(pending.receipts) === text) {
8535
+ rt.pendingSettled = undefined;
8536
+ return;
8537
+ }
8538
+ const receipts = pending.receipts.filter(receipt => receipt.text !== text);
8539
+ if (receipts.length === pending.receipts.length) return;
8540
+ rt.pendingSettled = receipts.length ? { ...pending, receipts } : undefined;
8541
+ };
8466
8542
 
8467
8543
  const flushPendingFinal = (rt: SessionRuntime, id: string): void => {
8468
8544
  const pending = rt.pendingFinal;
@@ -8479,7 +8555,7 @@ export function createNotificationsExtension(
8479
8555
  for (const receipt of pending.receipts)
8480
8556
  deferLeanReceipt(rt, receipt.text, receipt.messageRef, pending.window, receipt.origin);
8481
8557
  } else {
8482
- const text = pending.receipts.map(receipt => receipt.text).join("\n\n");
8558
+ const text = composeSettlementText(pending.receipts);
8483
8559
  const messageRef = pending.receipts.length === 1 ? pending.receipts[0]?.messageRef : undefined;
8484
8560
  try {
8485
8561
  pushSessionFrame(rt, {
@@ -8502,15 +8578,14 @@ export function createNotificationsExtension(
8502
8578
  const flushPendingSettled = (rt: SessionRuntime, id: string): void => {
8503
8579
  const settled = rt.pendingSettled;
8504
8580
  if (!settled?.receipts.length || !rt.notificationsActive || rt.redact || rt.policySuspended) return;
8505
- rt.pendingSettled = undefined;
8506
- const text = settled.receipts.map(receipt => receipt.text).join("\n\n");
8581
+ const text = composeSettlementText(settled.receipts);
8507
8582
  // A composition represents multiple finalized turns. It must be a fresh
8508
8583
  // terminal message rather than editing either constituent turn's stream.
8509
8584
  const messageRef = settled.receipts.length === 1 ? settled.receipts[0]?.messageRef : undefined;
8510
8585
  const previousLiveRef = rt.liveRef;
8511
8586
  if (messageRef) rt.liveRef = messageRef;
8512
8587
  try {
8513
- flushTurnText(rt, id, text, true);
8588
+ if (flushTurnText(rt, id, text, true)) rt.pendingSettled = undefined;
8514
8589
  } finally {
8515
8590
  if (!messageRef) rt.liveRef = previousLiveRef;
8516
8591
  }
@@ -8704,16 +8779,20 @@ export function createNotificationsExtension(
8704
8779
  const seq = rt.idleSeq++;
8705
8780
  // Re-assert the identity header so the daemon renames the topic once the
8706
8781
  // session title has been auto-generated ("{repo}/{branch} - {title}"). The
8707
- // daemon only renames when the title actually changed.
8782
+ // daemon only renames when the title actually changed. Bind the dependent
8783
+ // idle to the exact connection generations that accepted this positioned
8784
+ // identity; native raw fallbacks enqueue identity and idle as one bounded
8785
+ // writer command for the remaining snapshot cohort.
8708
8786
  try {
8709
- pushSessionFrame(rt, {
8787
+ const identity = {
8710
8788
  type: "identity_header",
8711
8789
  sessionId: id,
8712
8790
  ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName(), telegramTopicsEnabled()),
8713
- });
8714
- } catch {}
8715
- try {
8716
- rt.server.noteIdle(
8791
+ } as const;
8792
+ const receipts = emitSessionEventWithReceipts(rt, identity);
8793
+ const outcome = rt.server.queueIdleAfterDirected(
8794
+ JSON.stringify(identity),
8795
+ receipts,
8717
8796
  JSON.stringify(
8718
8797
  notificationActionPayload(
8719
8798
  {
@@ -8726,8 +8805,13 @@ export function createNotificationsExtension(
8726
8805
  ),
8727
8806
  ),
8728
8807
  );
8729
- } catch (e) {
8730
- logger.warn(`notifications: noteIdle failed: ${String(e)}`);
8808
+ if (outcome.status === "rejected" || outcome.status === "partial") {
8809
+ logger.warn(
8810
+ `notifications: dependent idle ${outcome.status} (${outcome.queuedCount}/${outcome.recipientCount} recipients queued)`,
8811
+ );
8812
+ }
8813
+ } catch (error) {
8814
+ logger.warn(`notifications: recipient-bound identity/idle delivery failed: ${String(error)}`);
8731
8815
  }
8732
8816
 
8733
8817
  // Lean: emit the latest deferred assistant answer exactly once at idle.
@@ -8778,13 +8862,10 @@ export function createNotificationsExtension(
8778
8862
  // The daemon coalesces/throttles these via its shared rate-limit pool.
8779
8863
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
8780
8864
  // against what was already flushed for this turn (the pre-ask lead-in).
8781
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
8782
- if (!text || text === rt.preAskFlushedText || !rt.notificationsActive || rt.policySuspended) return;
8783
- rt.preAskFlushedText = text;
8784
- // Ask lead-ins supersede any deferred lean settled answer from earlier turns
8785
- // so agent_end does not re-emit stale intermediate narration (#2863 review).
8786
- if (!finalAnswer && rt.currentTurnSettlementOrigin !== "autonomous" && !hasUserSettlementReceipt(rt))
8787
- rt.pendingSettled = undefined;
8865
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): boolean => {
8866
+ if (!text || text === rt.preAskFlushedText || !rt.notificationsActive || rt.policySuspended) return false;
8867
+ const hadUserSettlementReceipt = hasUserSettlementReceipt(rt);
8868
+ const settlementWindow = rt.currentTurnSettlementWindow ?? rt.settlementWindow;
8788
8869
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
8789
8870
  // live message, never a fresh (rich-promotable) send. If live frames were
8790
8871
  // async-queued and none landed before this flush, reuse the per-turn ref
@@ -8794,7 +8875,7 @@ export function createNotificationsExtension(
8794
8875
  rt.liveRef = String(rt.turnSeq);
8795
8876
  }
8796
8877
  try {
8797
- pushSessionFrame(rt, {
8878
+ const published = pushSessionFrame(rt, {
8798
8879
  type: "turn_stream",
8799
8880
  sessionId: id,
8800
8881
  phase: "finalized",
@@ -8802,8 +8883,24 @@ export function createNotificationsExtension(
8802
8883
  text,
8803
8884
  ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
8804
8885
  });
8886
+ if (!published) return false;
8887
+ rt.preAskFlushedText = text;
8888
+ if (!finalAnswer) {
8889
+ // A successfully published ask lead-in settles only exact matching
8890
+ // receipts from the same user-request window. Distinct retained user
8891
+ // receipts remain eligible for the idle summary (#4458).
8892
+ consumePublishedSettlement(rt, text, settlementWindow);
8893
+ // Ask lead-ins supersede deferred lean narration from earlier turns so
8894
+ // agent_end does not re-emit stale intermediate text (#2863 review).
8895
+ // Preserve the pre-publish user-receipt decision even when the exact
8896
+ // match above consumed that receipt.
8897
+ if (rt.currentTurnSettlementOrigin !== "autonomous" && !hadUserSettlementReceipt)
8898
+ rt.pendingSettled = undefined;
8899
+ }
8900
+ return true;
8805
8901
  } catch (e) {
8806
8902
  logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
8903
+ return false;
8807
8904
  }
8808
8905
  };
8809
8906
 
@@ -21,7 +21,14 @@ declare module "@gajae-code/natives" {
21
21
  interface RetireIfUnclaimedResult {
22
22
  status: "retired" | "already_terminal" | "claimed" | "stale";
23
23
  }
24
+ interface DependentIdleDeliveryResult {
25
+ status: "queued" | "no_recipients" | "rejected" | "partial";
26
+ recipientCount: number;
27
+ queuedCount: number;
28
+ }
24
29
  interface NotificationServer {
30
+ /** Broadcast a validated frame, omitting subscribers that received its positioned event envelope. */
31
+ pushFrame(json: string, excludedConnectionIds?: string[]): void;
25
32
  /** Register the raw v3 SDK frame callback. Must be called before start. */
26
33
  onSdkFrame(callback: (err: null | Error, frame: SdkFrameEvent) => void): void;
27
34
  /** Register the connection-close callback. Must be called before start. */
@@ -32,6 +39,14 @@ declare module "@gajae-code/natives" {
32
39
  ): void;
33
40
  /** Directed delivery of a validated, bounded JSON v3 envelope to one connection. */
34
41
  sendTo(connectionId: string, json: string): void;
42
+ /** Directed delivery with an opaque exact-generation receipt. */
43
+ sendToWithReceipt(connectionId: string, json: string): string;
44
+ /** Queue raw identity fallback and dependent idle on one exact bounded cohort. */
45
+ queueIdleAfterDirected(
46
+ prerequisiteJson: string,
47
+ positionedReceipts: string[],
48
+ neededJson: string,
49
+ ): DependentIdleDeliveryResult;
35
50
  /** Register a correlated workflow-gate action_needed frame. */
36
51
  registerWorkflowGateAsk(workflowJson: string, repliable: boolean): void;
37
52
  /** Atomically register the single active arbitrated action and return its private lease. */
@@ -45,7 +60,8 @@ declare module "@gajae-code/natives" {
45
60
  text: string,
46
61
  finalAnswer?: boolean,
47
62
  messageRef?: string,
48
- ): void;
63
+ excludedConnectionIds?: string[],
64
+ ): boolean;
49
65
  /** Broadcast raw file bytes; Rust encodes the unchanged base64 wire field. */
50
66
  pushFileAttachmentUnchecked(
51
67
  sessionId: string,
@@ -53,11 +69,14 @@ declare module "@gajae-code/natives" {
53
69
  mime: string | undefined,
54
70
  data: Buffer,
55
71
  caption: string | undefined,
72
+ excludedConnectionIds?: string[],
56
73
  ): void;
57
74
  /** Counters for known-good N-API frame crossings. */
58
75
  knownGoodFrameStats(): {
59
76
  knownGoodTurnStreamFrames: number;
60
77
  turnStreamSerdeValidationParses: number;
61
78
  };
79
+ /** Proves that optional raw fan-out exclusions are enforced by the loaded addon. */
80
+ supportsPositionedRawExclusion(): boolean;
62
81
  }
63
82
  }
@@ -280,8 +280,11 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
280
280
  * still running the pre-#4689 router keeps the old hot polling loop and its
281
281
  * lock contention, so an already-running owner must be replaced rather than
282
282
  * retained across this upgrade.
283
+ * Generation 177 consumes an exact same-window lean settlement receipt only
284
+ * after its autonomous ask lead-in publishes successfully, preventing idle
285
+ * replay without changing the serving protocol.
283
286
  */
284
- export const DAEMON_GENERATION = 176;
287
+ export const DAEMON_GENERATION = 178;
285
288
 
286
289
  /**
287
290
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7