@moltzap/client 2026.414.0 → 2026.425.1

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 (215) hide show
  1. package/dist/channel-core.d.ts +28 -15
  2. package/dist/channel-core.d.ts.map +1 -1
  3. package/dist/channel-core.js +113 -74
  4. package/dist/channel-core.js.map +1 -1
  5. package/dist/channel-core.test.js +126 -34
  6. package/dist/channel-core.test.js.map +1 -1
  7. package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts +2 -0
  8. package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts.map +1 -0
  9. package/dist/cli/__tests__/cli-multi-agent.int.test.js +26 -0
  10. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -0
  11. package/dist/cli/commands/agents.d.ts +13 -2
  12. package/dist/cli/commands/agents.d.ts.map +1 -1
  13. package/dist/cli/commands/agents.js +30 -18
  14. package/dist/cli/commands/agents.js.map +1 -1
  15. package/dist/cli/commands/apps.d.ts +96 -0
  16. package/dist/cli/commands/apps.d.ts.map +1 -0
  17. package/dist/cli/commands/apps.js +193 -0
  18. package/dist/cli/commands/apps.js.map +1 -0
  19. package/dist/cli/commands/apps.test.d.ts +2 -0
  20. package/dist/cli/commands/apps.test.d.ts.map +1 -0
  21. package/dist/cli/commands/apps.test.js +220 -0
  22. package/dist/cli/commands/apps.test.js.map +1 -0
  23. package/dist/cli/commands/contacts.d.ts +18 -2
  24. package/dist/cli/commands/contacts.d.ts.map +1 -1
  25. package/dist/cli/commands/contacts.js +44 -52
  26. package/dist/cli/commands/contacts.js.map +1 -1
  27. package/dist/cli/commands/conversations-v2.test.d.ts +2 -0
  28. package/dist/cli/commands/conversations-v2.test.d.ts.map +1 -0
  29. package/dist/cli/commands/conversations-v2.test.js +92 -0
  30. package/dist/cli/commands/conversations-v2.test.js.map +1 -0
  31. package/dist/cli/commands/conversations.d.ts +73 -3
  32. package/dist/cli/commands/conversations.d.ts.map +1 -1
  33. package/dist/cli/commands/conversations.js +157 -108
  34. package/dist/cli/commands/conversations.js.map +1 -1
  35. package/dist/cli/commands/invite.d.ts +6 -2
  36. package/dist/cli/commands/invite.d.ts.map +1 -1
  37. package/dist/cli/commands/invite.js +8 -6
  38. package/dist/cli/commands/invite.js.map +1 -1
  39. package/dist/cli/commands/messages.d.ts +48 -0
  40. package/dist/cli/commands/messages.d.ts.map +1 -0
  41. package/dist/cli/commands/messages.js +68 -0
  42. package/dist/cli/commands/messages.js.map +1 -0
  43. package/dist/cli/commands/messages.test.d.ts +2 -0
  44. package/dist/cli/commands/messages.test.d.ts.map +1 -0
  45. package/dist/cli/commands/messages.test.js +73 -0
  46. package/dist/cli/commands/messages.test.js.map +1 -0
  47. package/dist/cli/commands/permissions.d.ts +66 -0
  48. package/dist/cli/commands/permissions.d.ts.map +1 -0
  49. package/dist/cli/commands/permissions.js +101 -0
  50. package/dist/cli/commands/permissions.js.map +1 -0
  51. package/dist/cli/commands/permissions.test.d.ts +2 -0
  52. package/dist/cli/commands/permissions.test.d.ts.map +1 -0
  53. package/dist/cli/commands/permissions.test.js +126 -0
  54. package/dist/cli/commands/permissions.test.js.map +1 -0
  55. package/dist/cli/commands/ping.d.ts +6 -2
  56. package/dist/cli/commands/ping.d.ts.map +1 -1
  57. package/dist/cli/commands/ping.js +21 -20
  58. package/dist/cli/commands/ping.js.map +1 -1
  59. package/dist/cli/commands/presence.d.ts +10 -2
  60. package/dist/cli/commands/presence.d.ts.map +1 -1
  61. package/dist/cli/commands/presence.js +27 -15
  62. package/dist/cli/commands/presence.js.map +1 -1
  63. package/dist/cli/commands/register.d.ts +24 -2
  64. package/dist/cli/commands/register.d.ts.map +1 -1
  65. package/dist/cli/commands/register.js +123 -47
  66. package/dist/cli/commands/register.js.map +1 -1
  67. package/dist/cli/commands/register.test.js +45 -27
  68. package/dist/cli/commands/register.test.js.map +1 -1
  69. package/dist/cli/commands/send.d.ts +43 -2
  70. package/dist/cli/commands/send.d.ts.map +1 -1
  71. package/dist/cli/commands/send.js +55 -13
  72. package/dist/cli/commands/send.js.map +1 -1
  73. package/dist/cli/commands/send.test.js +20 -19
  74. package/dist/cli/commands/send.test.js.map +1 -1
  75. package/dist/cli/commands/status.d.ts +6 -2
  76. package/dist/cli/commands/status.d.ts.map +1 -1
  77. package/dist/cli/commands/status.js +16 -10
  78. package/dist/cli/commands/status.js.map +1 -1
  79. package/dist/cli/commands/whoami.d.ts +7 -2
  80. package/dist/cli/commands/whoami.d.ts.map +1 -1
  81. package/dist/cli/commands/whoami.js +13 -7
  82. package/dist/cli/commands/whoami.js.map +1 -1
  83. package/dist/cli/config.d.ts +24 -8
  84. package/dist/cli/config.d.ts.map +1 -1
  85. package/dist/cli/config.js +65 -39
  86. package/dist/cli/config.js.map +1 -1
  87. package/dist/cli/config.test.js +12 -10
  88. package/dist/cli/config.test.js.map +1 -1
  89. package/dist/cli/http-client.d.ts +6 -2
  90. package/dist/cli/http-client.d.ts.map +1 -1
  91. package/dist/cli/http-client.js +25 -24
  92. package/dist/cli/http-client.js.map +1 -1
  93. package/dist/cli/index.d.ts +16 -1
  94. package/dist/cli/index.d.ts.map +1 -1
  95. package/dist/cli/index.js +160 -27
  96. package/dist/cli/index.js.map +1 -1
  97. package/dist/cli/profile.d.ts +134 -0
  98. package/dist/cli/profile.d.ts.map +1 -0
  99. package/dist/cli/profile.js +217 -0
  100. package/dist/cli/profile.js.map +1 -0
  101. package/dist/cli/profile.test.d.ts +2 -0
  102. package/dist/cli/profile.test.d.ts.map +1 -0
  103. package/dist/cli/profile.test.js +263 -0
  104. package/dist/cli/profile.test.js.map +1 -0
  105. package/dist/cli/runtime.d.ts +34 -0
  106. package/dist/cli/runtime.d.ts.map +1 -0
  107. package/dist/cli/runtime.js +94 -0
  108. package/dist/cli/runtime.js.map +1 -0
  109. package/dist/cli/socket-client.d.ts +15 -12
  110. package/dist/cli/socket-client.d.ts.map +1 -1
  111. package/dist/cli/socket-client.js +72 -75
  112. package/dist/cli/socket-client.js.map +1 -1
  113. package/dist/cli/transport.d.ts +181 -0
  114. package/dist/cli/transport.d.ts.map +1 -0
  115. package/dist/cli/transport.js +324 -0
  116. package/dist/cli/transport.js.map +1 -0
  117. package/dist/cli/transport.test.d.ts +2 -0
  118. package/dist/cli/transport.test.d.ts.map +1 -0
  119. package/dist/cli/transport.test.js +197 -0
  120. package/dist/cli/transport.test.js.map +1 -0
  121. package/dist/index.d.ts +6 -2
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +1 -0
  124. package/dist/index.js.map +1 -1
  125. package/dist/runtime/close-info.d.ts +94 -0
  126. package/dist/runtime/close-info.d.ts.map +1 -0
  127. package/dist/runtime/close-info.js +109 -0
  128. package/dist/runtime/close-info.js.map +1 -0
  129. package/dist/runtime/close-info.test.d.ts +2 -0
  130. package/dist/runtime/close-info.test.d.ts.map +1 -0
  131. package/dist/runtime/close-info.test.js +102 -0
  132. package/dist/runtime/close-info.test.js.map +1 -0
  133. package/dist/runtime/errors.d.ts +47 -0
  134. package/dist/runtime/errors.d.ts.map +1 -0
  135. package/dist/runtime/errors.js +20 -0
  136. package/dist/runtime/errors.js.map +1 -0
  137. package/dist/runtime/errors.test.d.ts +2 -0
  138. package/dist/runtime/errors.test.d.ts.map +1 -0
  139. package/dist/runtime/errors.test.js +66 -0
  140. package/dist/runtime/errors.test.js.map +1 -0
  141. package/dist/runtime/frame.d.ts +25 -0
  142. package/dist/runtime/frame.d.ts.map +1 -0
  143. package/dist/runtime/frame.js +106 -0
  144. package/dist/runtime/frame.js.map +1 -0
  145. package/dist/runtime/frame.test.d.ts +2 -0
  146. package/dist/runtime/frame.test.d.ts.map +1 -0
  147. package/dist/runtime/frame.test.js +32 -0
  148. package/dist/runtime/frame.test.js.map +1 -0
  149. package/dist/runtime/index.d.ts +3 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +3 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/refs.d.ts +10 -0
  154. package/dist/runtime/refs.d.ts.map +1 -0
  155. package/dist/runtime/refs.js +10 -0
  156. package/dist/runtime/refs.js.map +1 -0
  157. package/dist/runtime/runtime.test.d.ts +2 -0
  158. package/dist/runtime/runtime.test.d.ts.map +1 -0
  159. package/dist/runtime/runtime.test.js +29 -0
  160. package/dist/runtime/runtime.test.js.map +1 -0
  161. package/dist/runtime/subscribers.d.ts +157 -0
  162. package/dist/runtime/subscribers.d.ts.map +1 -0
  163. package/dist/runtime/subscribers.js +131 -0
  164. package/dist/runtime/subscribers.js.map +1 -0
  165. package/dist/runtime/subscribers.test.d.ts +2 -0
  166. package/dist/runtime/subscribers.test.d.ts.map +1 -0
  167. package/dist/runtime/subscribers.test.js +155 -0
  168. package/dist/runtime/subscribers.test.js.map +1 -0
  169. package/dist/service.d.ts +68 -17
  170. package/dist/service.d.ts.map +1 -1
  171. package/dist/service.js +461 -307
  172. package/dist/service.js.map +1 -1
  173. package/dist/service.test.js +266 -77
  174. package/dist/service.test.js.map +1 -1
  175. package/dist/test/index.d.ts +33 -0
  176. package/dist/test/index.d.ts.map +1 -0
  177. package/dist/test/index.js +44 -0
  178. package/dist/test/index.js.map +1 -0
  179. package/dist/test-utils/channel-service-fixture.d.ts +2 -1
  180. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  181. package/dist/test-utils/channel-service-fixture.js +31 -11
  182. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  183. package/dist/test-utils/conformance-adapter.d.ts +50 -0
  184. package/dist/test-utils/conformance-adapter.d.ts.map +1 -0
  185. package/dist/test-utils/conformance-adapter.js +206 -0
  186. package/dist/test-utils/conformance-adapter.js.map +1 -0
  187. package/dist/test-utils/fake-service.d.ts +85 -0
  188. package/dist/test-utils/fake-service.d.ts.map +1 -0
  189. package/dist/test-utils/fake-service.js +103 -0
  190. package/dist/test-utils/fake-service.js.map +1 -0
  191. package/dist/test-utils/index.d.ts +2 -0
  192. package/dist/test-utils/index.d.ts.map +1 -1
  193. package/dist/test-utils/index.js +3 -2
  194. package/dist/test-utils/index.js.map +1 -1
  195. package/dist/ws-client.d.ts +157 -18
  196. package/dist/ws-client.d.ts.map +1 -1
  197. package/dist/ws-client.js +432 -94
  198. package/dist/ws-client.js.map +1 -1
  199. package/dist/ws-client.test.d.ts +2 -0
  200. package/dist/ws-client.test.d.ts.map +1 -0
  201. package/dist/ws-client.test.js +672 -0
  202. package/dist/ws-client.test.js.map +1 -0
  203. package/package.json +17 -8
  204. package/dist/__tests__/service.integration.test.d.ts +0 -2
  205. package/dist/__tests__/service.integration.test.d.ts.map +0 -1
  206. package/dist/__tests__/service.integration.test.js +0 -1087
  207. package/dist/__tests__/service.integration.test.js.map +0 -1
  208. package/dist/cli/commands/delete.d.ts +0 -3
  209. package/dist/cli/commands/delete.d.ts.map +0 -1
  210. package/dist/cli/commands/delete.js +0 -10
  211. package/dist/cli/commands/delete.js.map +0 -1
  212. package/dist/cli/commands/react.d.ts +0 -3
  213. package/dist/cli/commands/react.d.ts.map +0 -1
  214. package/dist/cli/commands/react.js +0 -18
  215. package/dist/cli/commands/react.js.map +0 -1
package/dist/service.js CHANGED
@@ -3,7 +3,10 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as os from "node:os";
5
5
  import { EventNames, } from "@moltzap/protocol";
6
+ import { Effect, HashMap, Match, Option, Ref } from "effect";
6
7
  import { MoltZapWsClient } from "./ws-client.js";
8
+ import { AgentNotFoundError, NotConnectedError, RpcServerError, RpcTimeoutError, } from "./runtime/errors.js";
9
+ import { getOr, snapshot } from "./runtime/refs.js";
7
10
  /** Escape `<`, `>`, `&` so sender content can't escape a `<system-reminder>` block. */
8
11
  export function sanitizeForSystemReminder(s) {
9
12
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -28,89 +31,144 @@ export function formatCrossConversationBlock(entries, opts) {
28
31
  "</system-reminder>",
29
32
  ].join("\n");
30
33
  }
31
- function renderPart(p) {
32
- switch (p.type) {
33
- case "text":
34
- return p.text;
35
- case "image":
36
- return "[image]";
37
- case "file":
38
- return `[file: ${p.name}]`;
39
- default:
40
- return `[${p.type}]`;
34
+ const renderPart = Match.type().pipe(Match.discriminatorsExhaustive("type")({
35
+ text: (t) => t.text,
36
+ image: () => "[image]",
37
+ file: (f) => `[file: ${f.name}]`,
38
+ }));
39
+ /**
40
+ * Per-conversation message cap. Older messages are evicted FIFO; the
41
+ * on-disk history remains the source of truth. Sized for typical CLI
42
+ * display windows — `conversations get` shows at most a few hundred.
43
+ */
44
+ const MAX_MESSAGES_PER_CONV = 1000;
45
+ /**
46
+ * Invoke every handler with `arg`, isolating throws so one bad handler
47
+ * doesn't abort the remaining fanout. Logs via the optional client logger.
48
+ */
49
+ function fanout(handlers, arg, logger) {
50
+ for (const h of handlers) {
51
+ try {
52
+ h(arg);
53
+ }
54
+ catch (err) {
55
+ logger?.error("event handler threw", err);
56
+ }
41
57
  }
42
58
  }
43
59
  /**
44
60
  * Stateful MoltZap client that manages connection, conversation tracking,
45
61
  * agent name resolution, and cross-conversation context generation.
62
+ *
63
+ * API contract: **every fallible method returns `Effect`.** There are no
64
+ * `*Async` Promise siblings (unlike `@moltzap/app-sdk`'s `MoltZapApp`);
65
+ * async/await consumers run the Effect at the edge with `Effect.runPromise`.
66
+ * `@moltzap/app-sdk` is the public app-facing surface and layers its own
67
+ * `*Async` wrappers on top. Keep this class Effect-only so downstream
68
+ * callers compose failures and cancellation explicitly.
46
69
  */
47
70
  export class MoltZapService {
48
71
  opts;
49
72
  client = null;
50
73
  _connected = false;
51
- conversations = new Map();
52
- messages = new Map();
53
- agentNames = new Map();
54
- agentConversationCache = new Map();
55
- lastNotified = new Map();
56
- lastRead = new Map();
74
+ conversationsRef = Effect.runSync(Ref.make(HashMap.empty()));
75
+ messagesRef = Effect.runSync(Ref.make(HashMap.empty()));
76
+ agentNamesRef = Effect.runSync(Ref.make(HashMap.empty()));
77
+ agentConversationCacheRef = Effect.runSync(Ref.make(HashMap.empty()));
78
+ lastNotifiedRef = Effect.runSync(Ref.make(HashMap.empty()));
79
+ lastReadRef = Effect.runSync(Ref.make(HashMap.empty()));
57
80
  messageHandlers = [];
58
81
  rawEventHandlers = [];
59
82
  disconnectHandlers = [];
60
83
  reconnectHandlers = [];
61
- pendingNameLookups = new Map();
62
- ownAgentId;
84
+ permissionRequiredHandlers = [];
85
+ _ownAgentId;
63
86
  constructor(opts) {
64
87
  this.opts = opts;
65
88
  }
66
89
  get connected() {
67
90
  return this._connected;
68
91
  }
69
- async connect() {
70
- this.client = new MoltZapWsClient({
71
- serverUrl: this.opts.serverUrl,
72
- agentKey: this.opts.agentKey,
73
- logger: this.opts.logger,
74
- onEvent: (event) => this.handleEvent(event),
75
- onDisconnect: () => {
76
- this._connected = false;
77
- for (const h of this.disconnectHandlers)
78
- h();
79
- },
80
- onReconnect: (helloOk) => {
81
- this._connected = true;
82
- const hello = helloOk;
83
- this.populateFromHello(hello);
84
- for (const h of this.reconnectHandlers)
85
- h(hello);
86
- },
92
+ get ownAgentId() {
93
+ return this._ownAgentId;
94
+ }
95
+ /** Effect-native: compose via `yield*` or bridge at the edge via `Effect.runPromise`. */
96
+ connect() {
97
+ return Effect.gen(this, function* () {
98
+ this.client = new MoltZapWsClient({
99
+ serverUrl: this.opts.serverUrl,
100
+ agentKey: this.opts.agentKey,
101
+ logger: this.opts.logger,
102
+ // Spec #222 OQ-6: arg required. The body doesn't branch on
103
+ // close metadata today; signature kept explicit so a future
104
+ // disconnect-handler chain can plumb code/reason through.
105
+ onDisconnect: (_close) => {
106
+ this._connected = false;
107
+ fanout(this.disconnectHandlers, undefined, this.opts.logger);
108
+ },
109
+ onReconnect: (helloOk) => {
110
+ this._connected = true;
111
+ const hello = helloOk;
112
+ this.populateFromHello(hello);
113
+ fanout(this.reconnectHandlers, hello, this.opts.logger);
114
+ },
115
+ });
116
+ // Spec #222 OQ-4 deletion: per-event `onEvent` callback is gone.
117
+ // Replacement: register a `{}` filter subscription before
118
+ // `connect()` so every inbound event still fans out to
119
+ // `handleEvent`. Pre-connect registration is supported by the
120
+ // registry.
121
+ yield* this.client.subscribe({}, (event) => Effect.sync(() => this.handleEvent(event)));
122
+ const helloOk = (yield* this.client.connect());
123
+ this._connected = true;
124
+ this._ownAgentId = helloOk.agentId;
125
+ this.populateFromHello(helloOk);
126
+ return helloOk;
87
127
  });
88
- const helloOk = (await this.client.connect());
89
- this._connected = true;
90
- this.ownAgentId = helloOk.agentId;
91
- this.populateFromHello(helloOk);
92
- return helloOk;
93
128
  }
129
+ /**
130
+ * Tear down the service. `close()` is sync because it fans out to the
131
+ * socket server, Refs, and the ws-client — the ws-client's own close
132
+ * Effect is run via `Effect.runSync` here so the caller gets an immediate
133
+ * shutdown the way existing code expects.
134
+ */
94
135
  close() {
95
136
  this._connected = false;
96
137
  this.stopSocketServer();
97
- this.client?.close();
138
+ if (this.client) {
139
+ void Effect.runPromise(this.client.close());
140
+ }
98
141
  this.client = null;
99
- this.conversations.clear();
100
- this.messages.clear();
101
- this.agentNames.clear();
102
- this.agentConversationCache.clear();
103
- this.lastNotified.clear();
104
- this.lastRead.clear();
142
+ Effect.runSync(Effect.all([
143
+ Ref.set(this.conversationsRef, HashMap.empty()),
144
+ Ref.set(this.messagesRef, HashMap.empty()),
145
+ Ref.set(this.agentNamesRef, HashMap.empty()),
146
+ Ref.set(this.agentConversationCacheRef, HashMap.empty()),
147
+ Ref.set(this.lastNotifiedRef, HashMap.empty()),
148
+ Ref.set(this.lastReadRef, HashMap.empty()),
149
+ ]));
150
+ // Handlers are preserved across close()/connect() cycles. MoltZapChannelCore
151
+ // subscribes once in its constructor; clearing handlers here would silently
152
+ // drop inbound/reconnect dispatch on any subsequent reconnect of the same
153
+ // service instance.
105
154
  }
106
155
  // --- Socket Server ---
107
156
  socketServer = null;
108
157
  activeSocketPath = null;
109
158
  /** Default socket path for CLI discovery. Per-instance path uses agentId. */
110
159
  static SOCKET_PATH = path.join(os.homedir(), ".moltzap", "service.sock");
160
+ /**
161
+ * `agentId` is a server-assigned string. Treat it as untrusted: if a
162
+ * compromised or malicious server returns an id containing `..` or a
163
+ * path separator, a naive `path.join(... , agentId)` escapes `~/.moltzap`.
164
+ * Reject anything that isn't a safe identifier.
165
+ */
166
+ static safeAgentIdSegment(id) {
167
+ return /^[A-Za-z0-9_-]+$/.test(id) ? id : "default";
168
+ }
111
169
  /** Per-instance socket path based on connected agentId. */
112
170
  get socketPath() {
113
- const id = this.ownAgentId ?? "default";
171
+ const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
114
172
  return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
115
173
  }
116
174
  startSocketServer() {
@@ -119,7 +177,14 @@ export class MoltZapService {
119
177
  try {
120
178
  fs.unlinkSync(sockPath);
121
179
  }
122
- catch { }
180
+ catch (err) {
181
+ // ENOENT is the normal case (no stale socket); log everything
182
+ // else (permission denied, EACCES) so operators can diagnose.
183
+ const code = err.code;
184
+ if (code !== "ENOENT") {
185
+ this.opts.logger?.warn("unlink existing socket failed", err);
186
+ }
187
+ }
123
188
  fs.mkdirSync(path.dirname(sockPath), { recursive: true });
124
189
  this.socketServer = net.createServer((conn) => {
125
190
  let buffer = "";
@@ -130,20 +195,39 @@ export class MoltZapService {
130
195
  for (const line of lines) {
131
196
  if (!line.trim())
132
197
  continue;
133
- void this.handleSocketLine(line, conn);
198
+ // Fork the line-handler Effect onto the Effect runtime. Each line
199
+ // dispatches independently; the fiber runs until it writes a
200
+ // response or error frame back onto the socket.
201
+ Effect.runFork(this.handleSocketLineEffect(line, conn));
134
202
  }
135
203
  });
136
204
  });
137
- this.socketServer.listen(sockPath);
205
+ this.socketServer.listen(sockPath, () => {
206
+ // Owner-only permissions: the socket exposes an RPC passthrough that
207
+ // impersonates this agent to the server, so other local users on the
208
+ // host must not be able to connect.
209
+ try {
210
+ fs.chmodSync(sockPath, 0o600);
211
+ }
212
+ catch (err) {
213
+ this.opts.logger?.warn("chmod 0600 on socket failed", err);
214
+ }
215
+ });
138
216
  // Symlink default path to this instance for CLI discovery
139
217
  try {
140
218
  fs.unlinkSync(MoltZapService.SOCKET_PATH);
141
219
  }
142
- catch { }
220
+ catch (err) {
221
+ // Stale/missing default symlink is expected most of the time; log
222
+ // at debug so real permission errors still surface.
223
+ this.opts.logger?.info("unlink default socket symlink", err);
224
+ }
143
225
  try {
144
226
  fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
145
227
  }
146
- catch { }
228
+ catch (err) {
229
+ this.opts.logger?.warn("symlink default socket failed", err);
230
+ }
147
231
  }
148
232
  stopSocketServer() {
149
233
  this.socketServer?.close();
@@ -153,193 +237,221 @@ export class MoltZapService {
153
237
  try {
154
238
  fs.unlinkSync(sockPath);
155
239
  }
156
- catch { }
157
- // Remove default symlink if it points to this instance
240
+ catch (err) {
241
+ this.opts.logger?.info("unlink socket path", err);
242
+ }
243
+ // Remove default symlink only if it points to this instance.
158
244
  try {
159
245
  const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
160
246
  if (target === sockPath)
161
247
  fs.unlinkSync(MoltZapService.SOCKET_PATH);
162
248
  }
163
- catch { }
249
+ catch (err) {
250
+ this.opts.logger?.info("cleanup default symlink", err);
251
+ }
164
252
  }
165
- async handleSocketLine(line, conn) {
166
- try {
167
- const req = JSON.parse(line);
253
+ /**
254
+ * Handle one JSON line from the unix socket as an Effect. Parses the
255
+ * request, dispatches through `handleSocketRequestEffect`, and writes
256
+ * either a `{result}` or `{error}` frame back onto the connection.
257
+ * Never fails — all branches resolve into a `conn.write` side effect.
258
+ */
259
+ handleSocketLineEffect(line, conn) {
260
+ return Effect.suspend(() => {
261
+ let req;
262
+ try {
263
+ req = JSON.parse(line); // #ignore-sloppy-code[record-cast]: JSON.parse boundary from unix socket line protocol
264
+ }
265
+ catch (err) {
266
+ return Effect.sync(() => conn.write(JSON.stringify({
267
+ error: err instanceof Error ? err.message : String(err),
268
+ }) + "\n"));
269
+ }
168
270
  if (typeof req.method !== "string" || !req.method) {
169
- throw new Error("method is required and must be a string");
271
+ return Effect.sync(() => conn.write(JSON.stringify({
272
+ error: "method is required and must be a string",
273
+ }) + "\n"));
170
274
  }
171
275
  const params = req.params != null && typeof req.params === "object"
172
- ? req.params
276
+ ? req.params // #ignore-sloppy-code[record-cast]: req.params is untyped JSON from socket
173
277
  : {};
174
- const result = await this.handleSocketRequest(req.method, params);
175
- conn.write(JSON.stringify({ result }) + "\n");
176
- }
177
- catch (err) {
178
- conn.write(JSON.stringify({
179
- error: err instanceof Error ? err.message : String(err),
180
- }) + "\n");
181
- }
278
+ return this.handleSocketRequestEffect(req.method, params).pipe(Effect.match({
279
+ onSuccess: (result) => {
280
+ conn.write(JSON.stringify({ result }) + "\n");
281
+ },
282
+ onFailure: (err) => {
283
+ conn.write(JSON.stringify({
284
+ error: err instanceof Error ? err.message : String(err),
285
+ }) + "\n");
286
+ },
287
+ }));
288
+ });
289
+ }
290
+ handleSocketRequestEffect(method, params) {
291
+ return Effect.suspend(() => {
292
+ switch (method) {
293
+ case "ping":
294
+ return Effect.succeed({ ok: true, agentId: this.ownAgentId });
295
+ case "status":
296
+ return Effect.succeed({
297
+ agentId: this.ownAgentId,
298
+ connected: this._connected,
299
+ conversations: this.getConversations().length,
300
+ });
301
+ case "history":
302
+ return this.handleHistoryRequest(params);
303
+ default:
304
+ // Passthrough: forward any other method to the MoltZap server via RPC
305
+ return this.sendRpc(method, params);
306
+ }
307
+ });
182
308
  }
183
- async handleSocketRequest(method, params) {
184
- switch (method) {
185
- case "ping":
186
- return { ok: true, agentId: this.ownAgentId };
187
- case "status":
309
+ handleHistoryRequest(params) {
310
+ return Effect.gen(this, function* () {
311
+ if (typeof params.conversationId !== "string" || !params.conversationId) {
312
+ return yield* Effect.fail(new Error("conversationId is required and must be a string"));
313
+ }
314
+ if (params.limit !== undefined && typeof params.limit !== "number") {
315
+ return yield* Effect.fail(new Error("limit must be a number"));
316
+ }
317
+ const convId = params.conversationId;
318
+ const limit = params.limit ?? 10;
319
+ const sessionKey = params.sessionKey;
320
+ const result = (yield* this.sendRpc("messages/list", {
321
+ conversationId: convId,
322
+ limit,
323
+ }));
324
+ // Resolve agent names (batch) + fetch conversation metadata (concurrent)
325
+ const knownNames = yield* Ref.get(this.agentNamesRef);
326
+ const unknownAgentIds = [
327
+ ...new Set(result.messages.map((m) => m.senderId)),
328
+ ].filter((id) => !HashMap.has(knownNames, id));
329
+ const lookupEff = unknownAgentIds.length > 0
330
+ ? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).pipe(Effect.tap((res) => {
331
+ const agents = res.agents;
332
+ return Ref.update(this.agentNamesRef, (names) => {
333
+ let next = names;
334
+ for (const a of agents) {
335
+ next = HashMap.set(next, a.id, a.name);
336
+ }
337
+ return next;
338
+ });
339
+ }), Effect.asVoid, Effect.catchAll(() => Effect.void))
340
+ : Effect.void;
341
+ const metaEff = this.sendRpc("conversations/get", {
342
+ conversationId: convId,
343
+ }).pipe(Effect.map((res) => res
344
+ .conversation), Effect.catchAll(() => Effect.succeed(undefined)));
345
+ const [, convMeta] = yield* Effect.all([lookupEff, metaEff], {
346
+ concurrency: "unbounded",
347
+ });
348
+ // Determine what's "new" using lastRead (not lastNotified).
349
+ // lastNotified is advanced by getContext() (system-reminder).
350
+ // lastRead is advanced here when the agent explicitly reads history.
351
+ const allAgentNames = yield* Ref.get(this.agentNamesRef);
352
+ const lastReadMap = yield* Ref.get(this.lastReadRef);
353
+ const lastReadIds = sessionKey
354
+ ? Option.getOrElse(Option.flatMap(HashMap.get(lastReadMap, sessionKey), (perConv) => HashMap.get(perConv, convId)), () => new Set())
355
+ : new Set();
356
+ const messages = result.messages.map((m) => {
357
+ const text = m.parts.map(renderPart).join(" ");
358
+ const senderName = Option.getOrElse(HashMap.get(allAgentNames, m.senderId), () => m.senderId);
188
359
  return {
189
- agentId: this.ownAgentId,
190
- connected: this._connected,
191
- conversations: this.getConversations().length,
360
+ id: m.id,
361
+ senderId: m.senderId,
362
+ senderName: m.senderId === this.ownAgentId ? "you" : senderName,
363
+ isOwn: m.senderId === this.ownAgentId,
364
+ text,
365
+ createdAt: m.createdAt,
366
+ isNew: sessionKey ? !lastReadIds.has(m.id) : false,
192
367
  };
193
- case "history": {
194
- if (typeof params.conversationId !== "string" ||
195
- !params.conversationId) {
196
- throw new Error("conversationId is required and must be a string");
197
- }
198
- if (params.limit !== undefined && typeof params.limit !== "number") {
199
- throw new Error("limit must be a number");
200
- }
201
- const convId = params.conversationId;
202
- const limit = params.limit ?? 10;
203
- const sessionKey = params.sessionKey;
204
- const afterSeq = params.afterSeq;
205
- const beforeSeq = params.beforeSeq;
206
- const result = (await this.sendRpc("messages/list", {
207
- conversationId: convId,
208
- limit,
209
- ...(afterSeq !== undefined ? { afterSeq } : {}),
210
- ...(beforeSeq !== undefined ? { beforeSeq } : {}),
211
- }));
212
- // Resolve agent names (batch) + fetch conversation metadata (concurrent)
213
- const unknownAgentIds = [
214
- ...new Set(result.messages
215
- .filter((m) => m.sender.type === "agent")
216
- .map((m) => m.sender.id)),
217
- ].filter((id) => !this.agentNames.has(id));
218
- const [, convMeta] = await Promise.all([
219
- unknownAgentIds.length > 0
220
- ? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).then((res) => {
221
- for (const a of res.agents) {
222
- this.agentNames.set(a.id, a.name);
223
- }
224
- })
225
- : Promise.resolve(),
226
- this.sendRpc("conversations/get", { conversationId: convId })
227
- .then((res) => res
228
- .conversation)
229
- .catch(() => undefined),
230
- ]);
231
- // Determine what's "new" using lastRead (not lastNotified).
232
- // lastNotified is advanced by getContext() (system-reminder).
233
- // lastRead is advanced here when the agent explicitly reads history.
234
- // This means messages stay "new" until the agent reads them via history,
235
- // even if the system-reminder already notified about them.
236
- const readMarkers = sessionKey
237
- ? (this.lastRead.get(sessionKey) ?? new Map())
238
- : null;
239
- const lastReadSeq = readMarkers?.get(convId) ?? 0;
240
- const messages = result.messages.map((m) => {
241
- const text = m.parts.map(renderPart).join(" ");
242
- return {
243
- seq: m.seq,
244
- senderId: m.sender.id,
245
- senderName: m.sender.id === this.ownAgentId
246
- ? "you"
247
- : (this.agentNames.get(m.sender.id) ?? m.sender.id),
248
- isOwn: m.sender.id === this.ownAgentId,
249
- text,
250
- createdAt: m.createdAt,
251
- isNew: sessionKey ? m.seq > lastReadSeq : false,
252
- };
368
+ });
369
+ // Advance lastRead to include all message IDs in the returned page.
370
+ if (sessionKey && result.messages.length > 0) {
371
+ yield* Ref.update(this.lastReadRef, (outer) => {
372
+ const perSession = getOr(outer, sessionKey, () => HashMap.empty());
373
+ const existing = getOr(perSession, convId, () => new Set());
374
+ if (result.messages.every((m) => existing.has(m.id)))
375
+ return outer;
376
+ const nextSet = new Set(existing);
377
+ for (const m of result.messages)
378
+ nextSet.add(m.id);
379
+ return HashMap.set(outer, sessionKey, HashMap.set(perSession, convId, nextSet));
253
380
  });
254
- // Advance lastRead to the highest seq in the returned page.
255
- // The agent has seen these messages; older ones (if paginated) remain
256
- // accessible via afterSeq/beforeSeq cursors.
257
- if (sessionKey && result.messages.length > 0) {
258
- const maxSeq = Math.max(...result.messages.map((m) => m.seq));
259
- if (!this.lastRead.has(sessionKey)) {
260
- this.lastRead.set(sessionKey, new Map());
261
- }
262
- const current = this.lastRead.get(sessionKey).get(convId) ?? 0;
263
- if (maxSeq > current) {
264
- this.lastRead.get(sessionKey).set(convId, maxSeq);
265
- }
266
- }
267
- return {
268
- messages,
269
- hasMore: result.hasMore,
270
- conversationMeta: convMeta,
271
- newCount: messages.filter((m) => m.isNew).length,
272
- };
273
381
  }
274
- default:
275
- // Passthrough: forward any other method to the MoltZap server via RPC
276
- return this.sendRpc(method, params);
277
- }
382
+ return {
383
+ messages,
384
+ hasMore: result.hasMore,
385
+ conversationMeta: convMeta,
386
+ newCount: messages.filter((m) => m.isNew).length,
387
+ };
388
+ });
278
389
  }
279
390
  // --- Conversations ---
280
391
  getConversation(convId) {
281
- return this.conversations.get(convId);
392
+ return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
282
393
  }
283
394
  getConversations() {
284
- return [...this.conversations.values()];
395
+ return [...HashMap.values(snapshot(this.conversationsRef))];
285
396
  }
286
397
  // --- Messages ---
287
398
  getHistory(convId, limit) {
288
- const msgs = this.messages.get(convId) ?? [];
399
+ const msgs = getOr(snapshot(this.messagesRef), convId, () => []);
289
400
  return limit ? msgs.slice(-limit) : [...msgs];
290
401
  }
291
402
  // --- Agent Names ---
292
403
  getAgentName(agentId) {
293
- return this.agentNames.get(agentId);
294
- }
295
- async resolveAgentName(agentId) {
296
- const cached = this.agentNames.get(agentId);
297
- if (cached)
298
- return cached;
299
- const pending = this.pendingNameLookups.get(agentId);
300
- if (pending)
301
- return pending;
302
- const promise = (async () => {
303
- const result = (await this.sendRpc("agents/lookup", {
304
- agentIds: [agentId],
305
- }));
306
- const agent = result.agents[0];
307
- if (agent) {
308
- this.agentNames.set(agentId, agent.name);
309
- return agent.name;
310
- }
311
- return agentId;
312
- })();
313
- this.pendingNameLookups.set(agentId, promise);
314
- try {
315
- return await promise;
316
- }
317
- finally {
318
- this.pendingNameLookups.delete(agentId);
319
- }
404
+ return Option.getOrUndefined(HashMap.get(snapshot(this.agentNamesRef), agentId));
405
+ }
406
+ /**
407
+ * Cache-first agent-name lookup. Never fails: falls back to `agentId`
408
+ * when the RPC errors or the server has no record. The error path logs
409
+ * so ops can see repeated lookup failures; the empty-response path is
410
+ * silent (a cold agent is an expected transient state).
411
+ */
412
+ resolveAgentName(agentId) {
413
+ return Effect.gen(this, function* () {
414
+ const cached = Option.getOrUndefined(HashMap.get(snapshot(this.agentNamesRef), agentId));
415
+ if (cached !== undefined)
416
+ return cached;
417
+ return yield* this.sendRpc("agents/lookup", { agentIds: [agentId] }).pipe(Effect.flatMap((result) => {
418
+ const agent = result.agents[0];
419
+ if (!agent)
420
+ return Effect.succeed(agentId);
421
+ return Ref.update(this.agentNamesRef, (names) => HashMap.set(names, agentId, agent.name)).pipe(Effect.as(agent.name));
422
+ }), Effect.catchAll((err) => Effect.logWarning("agents/lookup failed; falling back to agentId").pipe(Effect.annotateLogs({ agentId, err: String(err) }), Effect.as(agentId))));
423
+ });
320
424
  }
321
425
  // --- Messaging ---
322
- async send(convId, text, opts) {
323
- await this.sendRpc("messages/send", {
426
+ send(convId, text, opts) {
427
+ return Effect.asVoid(this.sendRpc("messages/send", {
324
428
  conversationId: convId,
325
429
  parts: [{ type: "text", text }],
326
430
  ...(opts?.replyTo ? { replyToId: opts.replyTo } : {}),
327
- });
431
+ }));
328
432
  }
329
- async sendToAgent(agentName, text, opts) {
330
- let conversationId = this.agentConversationCache.get(agentName);
331
- if (!conversationId) {
332
- const lookupResult = (await this.sendRpc("agents/lookupByName", {
333
- name: agentName,
334
- }));
335
- const createResult = (await this.sendRpc("conversations/create", {
336
- type: "dm",
337
- participants: [{ type: "agent", id: lookupResult.agent.id }],
338
- }));
339
- conversationId = createResult.conversation.id;
340
- this.agentConversationCache.set(agentName, conversationId);
341
- }
342
- await this.send(conversationId, text, opts);
433
+ sendToAgent(agentName, text, opts) {
434
+ return Effect.gen(this, function* () {
435
+ const cache = yield* Ref.get(this.agentConversationCacheRef);
436
+ let conversationId = Option.getOrUndefined(HashMap.get(cache, agentName));
437
+ if (!conversationId) {
438
+ const lookupResult = (yield* this.sendRpc("agents/lookupByName", {
439
+ names: [agentName],
440
+ }));
441
+ const agent = lookupResult.agents[0];
442
+ if (!agent) {
443
+ return yield* Effect.fail(new AgentNotFoundError({ agentName }));
444
+ }
445
+ const createResult = (yield* this.sendRpc("conversations/create", {
446
+ type: "dm",
447
+ participants: [{ type: "agent", id: agent.id }],
448
+ }));
449
+ conversationId = createResult.conversation.id;
450
+ const newId = conversationId;
451
+ yield* Ref.update(this.agentConversationCacheRef, (m) => HashMap.set(m, agentName, newId));
452
+ }
453
+ yield* this.send(conversationId, text, opts);
454
+ });
343
455
  }
344
456
  // --- Cross-Conversation Context ---
345
457
  /**
@@ -365,40 +477,43 @@ export class MoltZapService {
365
477
  peekContextEntries(currentConvId, opts) {
366
478
  const maxConvs = opts?.maxConversations ?? 5;
367
479
  const maxMsgsPerConv = opts?.maxMessagesPerConv ?? 3;
368
- const viewMarkers = this.lastNotified.get(currentConvId) ?? new Map();
480
+ const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
481
+ // Collect all candidate conversations with their last-message timestamp,
482
+ // then sort by recency before applying `maxConvs`. HashMap iteration
483
+ // order is hash-based (not insertion/recency), so without the sort the
484
+ // maxConvs truncation would pick an arbitrary subset and could omit the
485
+ // freshest conversations.
486
+ const candidates = [];
487
+ for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
488
+ const last = newMsgs[newMsgs.length - 1];
489
+ candidates.push({
490
+ convId,
491
+ newMsgs,
492
+ lastTs: new Date(last.createdAt).getTime(),
493
+ });
494
+ }
495
+ candidates.sort((a, b) => b.lastTs - a.lastTs);
369
496
  const entries = [];
370
497
  const pendingAdvances = [];
371
- for (const [convId, msgs] of this.messages) {
372
- if (convId === currentConvId || msgs.length === 0)
373
- continue;
374
- if (entries.length >= maxConvs)
375
- break;
376
- const lastSeenSeq = viewMarkers.get(convId) ?? 0;
377
- const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
378
- if (newMsgs.length === 0)
379
- continue;
498
+ for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
380
499
  const reportable = newMsgs.slice(-maxMsgsPerConv);
381
500
  const last = reportable[reportable.length - 1];
382
- const senderName = this.resolveSenderLabel(last.sender.id);
501
+ const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
383
502
  const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) / 60_000));
384
- const text = last.parts.map(renderPart).join(" ");
385
503
  entries.push({
386
504
  conversationId: convId,
387
- conversationName: this.conversations.get(convId)?.name,
505
+ conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
388
506
  senderName,
389
- text,
507
+ text: last.parts.map(renderPart).join(" "),
390
508
  minutesAgo,
391
509
  count: reportable.length,
392
510
  });
393
- pendingAdvances.push([convId, last.seq]);
511
+ pendingAdvances.push([convId, last.id]);
394
512
  }
395
- const commit = () => {
396
- for (const [convId, seq] of pendingAdvances) {
397
- viewMarkers.set(convId, seq);
398
- }
399
- this.lastNotified.set(currentConvId, viewMarkers);
513
+ return {
514
+ entries,
515
+ commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
400
516
  };
401
- return { entries, commit };
402
517
  }
403
518
  /**
404
519
  * Return all new messages from all other conversations as full transcripts,
@@ -406,43 +521,64 @@ export class MoltZapService {
406
521
  * peekContextEntries. Call commit() to advance markers.
407
522
  */
408
523
  peekFullMessages(currentConvId) {
409
- const viewMarkers = this.lastNotified.get(currentConvId) ?? new Map();
524
+ const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
410
525
  const allMessages = [];
411
526
  const pendingAdvances = [];
412
- for (const [convId, msgs] of this.messages) {
413
- if (convId === currentConvId || msgs.length === 0)
414
- continue;
415
- const lastSeenSeq = viewMarkers.get(convId) ?? 0;
416
- const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
417
- if (newMsgs.length === 0)
418
- continue;
419
- const convName = this.conversations.get(convId)?.name;
527
+ for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
528
+ const convName = Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name;
420
529
  for (const m of newMsgs) {
421
- const text = m.parts.map(renderPart).join(" ");
422
530
  allMessages.push({
423
531
  conversationId: convId,
424
532
  conversationName: convName,
425
- senderName: this.resolveSenderLabel(m.sender.id),
426
- senderId: m.sender.id,
427
- text,
533
+ senderName: getOr(agentNamesMap, m.senderId, () => m.senderId),
534
+ senderId: m.senderId,
535
+ text: m.parts.map(renderPart).join(" "),
428
536
  timestamp: m.createdAt,
429
537
  });
430
538
  }
431
- const lastSeq = newMsgs[newMsgs.length - 1].seq;
432
- pendingAdvances.push([convId, lastSeq]);
539
+ pendingAdvances.push([convId, newMsgs[newMsgs.length - 1].id]);
433
540
  }
434
541
  allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
435
- const commit = () => {
436
- for (const [convId, seq] of pendingAdvances) {
437
- viewMarkers.set(convId, seq);
438
- }
439
- this.lastNotified.set(currentConvId, viewMarkers);
542
+ return {
543
+ messages: allMessages,
544
+ commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
545
+ };
546
+ }
547
+ readCrossConvState(currentConvId) {
548
+ const lastNotifiedMap = snapshot(this.lastNotifiedRef);
549
+ return {
550
+ messagesMap: snapshot(this.messagesRef),
551
+ conversationsMap: snapshot(this.conversationsRef),
552
+ agentNamesMap: snapshot(this.agentNamesRef),
553
+ viewMarkers: getOr(lastNotifiedMap, currentConvId, () => HashMap.empty()),
440
554
  };
441
- return { messages: allMessages, commit };
442
555
  }
443
- on(event,
444
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
- handler) {
556
+ *iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId) {
557
+ for (const [convId, msgs] of messagesMap) {
558
+ if (convId === currentConvId || msgs.length === 0)
559
+ continue;
560
+ const lastSeenId = Option.getOrUndefined(HashMap.get(viewMarkers, convId));
561
+ const lastSeenIdx = lastSeenId
562
+ ? msgs.findIndex((m) => m.id === lastSeenId)
563
+ : -1;
564
+ const newMsgs = msgs.slice(lastSeenIdx + 1);
565
+ if (newMsgs.length === 0)
566
+ continue;
567
+ yield [convId, newMsgs];
568
+ }
569
+ }
570
+ advanceLastNotified(currentConvId, pendingAdvances) {
571
+ if (pendingAdvances.length === 0)
572
+ return;
573
+ Effect.runSync(Ref.update(this.lastNotifiedRef, (outer) => {
574
+ let markers = getOr(outer, currentConvId, () => HashMap.empty());
575
+ for (const [convId, msgId] of pendingAdvances) {
576
+ markers = HashMap.set(markers, convId, msgId);
577
+ }
578
+ return HashMap.set(outer, currentConvId, markers);
579
+ }));
580
+ }
581
+ on(event, handler) {
446
582
  switch (event) {
447
583
  case "message":
448
584
  this.messageHandlers.push(handler);
@@ -456,13 +592,22 @@ export class MoltZapService {
456
592
  case "reconnect":
457
593
  this.reconnectHandlers.push(handler);
458
594
  break;
595
+ case "permissionRequired":
596
+ this.permissionRequiredHandlers.push(handler);
597
+ break;
459
598
  }
460
599
  }
461
600
  // --- RPC passthrough ---
462
- async sendRpc(method, params) {
463
- if (!this.client)
464
- throw new Error("Not connected");
465
- return this.client.sendRpc(method, params);
601
+ sendRpc(method, params) {
602
+ return Effect.suspend(() => {
603
+ if (!this.client) {
604
+ return Effect.fail(new NotConnectedError({ message: "Not connected" }));
605
+ }
606
+ return this.client.sendRpc(method, params);
607
+ });
608
+ }
609
+ grantPermission(params) {
610
+ return Effect.asVoid(this.sendRpc("permissions/grant", params));
466
611
  }
467
612
  // --- Internals ---
468
613
  /**
@@ -470,83 +615,92 @@ export class MoltZapService {
470
615
  * into the local cache. Called on ConversationCreated events because the
471
616
  * event schema omits the participants list — see protocol/events.ts.
472
617
  */
473
- async refreshConversationParticipants(conversationId) {
474
- try {
475
- const res = (await this.sendRpc("conversations/get", {
476
- conversationId,
477
- }));
478
- const existing = this.conversations.get(conversationId);
479
- this.conversations.set(conversationId, {
480
- id: res.conversation.id,
481
- type: res.conversation.type,
482
- name: res.conversation.name,
483
- participants: res.participants.map((p) => `${p.participant.type}:${p.participant.id}`),
484
- });
485
- }
486
- catch {
487
- // Best-effort refresh. Leave the existing entry in place on failure.
488
- }
618
+ refreshConversationParticipants(conversationId) {
619
+ return this.sendRpc("conversations/get", { conversationId }).pipe(Effect.tap((res) => {
620
+ const typed = res;
621
+ const meta = {
622
+ id: typed.conversation.id,
623
+ type: typed.conversation.type,
624
+ name: typed.conversation.name,
625
+ participants: typed.participants.map((p) => `${p.participant.type}:${p.participant.id}`),
626
+ };
627
+ return Ref.update(this.conversationsRef, (m) => HashMap.set(m, conversationId, meta));
628
+ }), Effect.asVoid,
629
+ // Best-effort refresh. Leave the existing entry in place on failure.
630
+ Effect.catchAll(() => Effect.void));
489
631
  }
490
632
  populateFromHello(hello) {
491
633
  if (!hello.conversations)
492
634
  return;
493
- for (const conv of hello.conversations) {
494
- this.conversations.set(conv.id, {
495
- id: conv.id,
496
- type: conv.type,
497
- name: conv.name,
498
- participants: (conv.participants ?? []).map((p) => `${p.type}:${p.id}`),
499
- });
500
- }
635
+ const incoming = hello.conversations;
636
+ Effect.runSync(Ref.update(this.conversationsRef, (m) => {
637
+ let next = m;
638
+ for (const conv of incoming) {
639
+ next = HashMap.set(next, conv.id, {
640
+ id: conv.id,
641
+ type: conv.type,
642
+ name: conv.name,
643
+ participants: (conv.participants ?? []).map((p) => `${p.type}:${p.id}`),
644
+ });
645
+ }
646
+ return next;
647
+ }));
501
648
  }
502
649
  handleEvent(event) {
503
- for (const h of this.rawEventHandlers)
504
- h(event);
650
+ fanout(this.rawEventHandlers, event, this.opts.logger);
651
+ // The server validates event.data against each event's schema before
652
+ // emitting; each case casts to the typed Static<> payload for that
653
+ // specific event.
505
654
  switch (event.event) {
506
655
  case EventNames.MessageReceived: {
507
656
  const msg = event.data.message;
508
657
  this.storeMessage(msg);
509
- // Resolve sender name in background
510
- if (msg.sender.type === "agent" &&
511
- !this.agentNames.has(msg.sender.id)) {
512
- void this.resolveAgentName(msg.sender.id);
513
- }
514
- // Emit to external handlers (only non-own messages)
515
- if (msg.sender.id !== this.ownAgentId) {
516
- for (const h of this.messageHandlers)
517
- h(msg);
658
+ // Name resolution is driven lazily by channel-core's serialized
659
+ // consumer via resolveAgentName(), which populates agentNamesRef on
660
+ // first miss and hits the cache on every subsequent message.
661
+ if (msg.senderId !== this._ownAgentId) {
662
+ fanout(this.messageHandlers, msg, this.opts.logger);
518
663
  }
519
664
  break;
520
665
  }
666
+ case EventNames.PermissionsRequired: {
667
+ fanout(this.permissionRequiredHandlers, event.data, this.opts.logger);
668
+ break;
669
+ }
521
670
  case EventNames.ConversationCreated:
522
671
  case EventNames.ConversationUpdated: {
523
- const data = event.data;
524
- const existing = this.conversations.get(data.conversation.id);
525
- this.conversations.set(data.conversation.id, {
526
- id: data.conversation.id,
527
- type: data.conversation.type,
528
- name: data.conversation.name,
529
- participants: existing?.participants ?? [],
530
- });
672
+ const { conversation } = event.data;
673
+ Effect.runSync(Ref.update(this.conversationsRef, (m) => {
674
+ const existing = Option.getOrUndefined(HashMap.get(m, conversation.id));
675
+ return HashMap.set(m, conversation.id, {
676
+ id: conversation.id,
677
+ type: conversation.type,
678
+ name: conversation.name,
679
+ participants: existing?.participants ?? [],
680
+ });
681
+ }));
531
682
  // The ConversationCreated event schema doesn't carry participants
532
683
  // (protocol/schema/events.ts: ConversationSchema is id/type/name/
533
684
  // createdBy/timestamps only). Fetch full details asynchronously so
534
685
  // downstream code that reads getConversation(id).participants sees
535
686
  // a populated list within a round-trip of the event.
536
687
  if (event.event === EventNames.ConversationCreated) {
537
- void this.refreshConversationParticipants(data.conversation.id);
688
+ // Fire-and-forget: refreshConversationParticipants never fails.
689
+ Effect.runFork(this.refreshConversationParticipants(conversation.id));
538
690
  }
539
691
  break;
540
692
  }
541
693
  }
542
694
  }
543
695
  storeMessage(msg) {
544
- const buf = this.messages.get(msg.conversationId) ?? [];
545
- buf.push(msg);
546
- this.messages.set(msg.conversationId, buf);
547
- }
548
- resolveSenderLabel(senderId) {
549
- return this.agentNames.get(senderId) ?? senderId;
696
+ Effect.runSync(Ref.update(this.messagesRef, (m) => {
697
+ const existing = getOr(m, msg.conversationId, () => []);
698
+ const appended = [...existing, msg];
699
+ const capped = appended.length > MAX_MESSAGES_PER_CONV
700
+ ? appended.slice(-MAX_MESSAGES_PER_CONV)
701
+ : appended;
702
+ return HashMap.set(m, msg.conversationId, capped);
703
+ }));
550
704
  }
551
705
  }
552
706
  //# sourceMappingURL=service.js.map