@moltzap/client 2026.413.0 → 2026.425.0

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 (216) hide show
  1. package/dist/channel-core.d.ts +33 -15
  2. package/dist/channel-core.d.ts.map +1 -1
  3. package/dist/channel-core.js +113 -64
  4. package/dist/channel-core.js.map +1 -1
  5. package/dist/channel-core.test.js +176 -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 +86 -17
  170. package/dist/service.d.ts.map +1 -1
  171. package/dist/service.js +481 -290
  172. package/dist/service.js.map +1 -1
  173. package/dist/service.test.js +386 -59
  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 +3 -1
  180. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  181. package/dist/test-utils/channel-service-fixture.js +42 -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 +24 -15
  204. package/LICENSE +0 -21
  205. package/dist/__tests__/service.integration.test.d.ts +0 -2
  206. package/dist/__tests__/service.integration.test.d.ts.map +0 -1
  207. package/dist/__tests__/service.integration.test.js +0 -999
  208. package/dist/__tests__/service.integration.test.js.map +0 -1
  209. package/dist/cli/commands/delete.d.ts +0 -3
  210. package/dist/cli/commands/delete.d.ts.map +0 -1
  211. package/dist/cli/commands/delete.js +0 -10
  212. package/dist/cli/commands/delete.js.map +0 -1
  213. package/dist/cli/commands/react.d.ts +0 -3
  214. package/dist/cli/commands/react.d.ts.map +0 -1
  215. package/dist/cli/commands/react.js +0 -18
  216. 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,90 +31,144 @@ export function formatCrossConversationBlock(entries, opts) {
28
31
  "</system-reminder>",
29
32
  ].join("\n");
30
33
  }
31
- const MAX_MESSAGES_PER_CONV = 20;
32
- function renderPart(p) {
33
- switch (p.type) {
34
- case "text":
35
- return p.text;
36
- case "image":
37
- return "[image]";
38
- case "file":
39
- return `[file: ${p.name}]`;
40
- default:
41
- 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
+ }
42
57
  }
43
58
  }
44
59
  /**
45
60
  * Stateful MoltZap client that manages connection, conversation tracking,
46
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.
47
69
  */
48
70
  export class MoltZapService {
49
71
  opts;
50
72
  client = null;
51
73
  _connected = false;
52
- conversations = new Map();
53
- messages = new Map();
54
- agentNames = new Map();
55
- agentConversationCache = new Map();
56
- lastNotified = new Map();
57
- 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()));
58
80
  messageHandlers = [];
59
81
  rawEventHandlers = [];
60
82
  disconnectHandlers = [];
61
83
  reconnectHandlers = [];
62
- pendingNameLookups = new Map();
63
- ownAgentId;
84
+ permissionRequiredHandlers = [];
85
+ _ownAgentId;
64
86
  constructor(opts) {
65
87
  this.opts = opts;
66
88
  }
67
89
  get connected() {
68
90
  return this._connected;
69
91
  }
70
- async connect() {
71
- this.client = new MoltZapWsClient({
72
- serverUrl: this.opts.serverUrl,
73
- agentKey: this.opts.agentKey,
74
- logger: this.opts.logger,
75
- onEvent: (event) => this.handleEvent(event),
76
- onDisconnect: () => {
77
- this._connected = false;
78
- for (const h of this.disconnectHandlers)
79
- h();
80
- },
81
- onReconnect: (helloOk) => {
82
- this._connected = true;
83
- const hello = helloOk;
84
- this.populateFromHello(hello);
85
- for (const h of this.reconnectHandlers)
86
- h(hello);
87
- },
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;
88
127
  });
89
- const helloOk = (await this.client.connect());
90
- this._connected = true;
91
- this.ownAgentId = helloOk.agentId;
92
- this.populateFromHello(helloOk);
93
- return helloOk;
94
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
+ */
95
135
  close() {
96
136
  this._connected = false;
97
137
  this.stopSocketServer();
98
- this.client?.close();
138
+ if (this.client) {
139
+ void Effect.runPromise(this.client.close());
140
+ }
99
141
  this.client = null;
100
- this.conversations.clear();
101
- this.messages.clear();
102
- this.agentNames.clear();
103
- this.agentConversationCache.clear();
104
- this.lastNotified.clear();
105
- 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.
106
154
  }
107
155
  // --- Socket Server ---
108
156
  socketServer = null;
109
157
  activeSocketPath = null;
110
158
  /** Default socket path for CLI discovery. Per-instance path uses agentId. */
111
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
+ }
112
169
  /** Per-instance socket path based on connected agentId. */
113
170
  get socketPath() {
114
- const id = this.ownAgentId ?? "default";
171
+ const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
115
172
  return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
116
173
  }
117
174
  startSocketServer() {
@@ -120,7 +177,14 @@ export class MoltZapService {
120
177
  try {
121
178
  fs.unlinkSync(sockPath);
122
179
  }
123
- 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
+ }
124
188
  fs.mkdirSync(path.dirname(sockPath), { recursive: true });
125
189
  this.socketServer = net.createServer((conn) => {
126
190
  let buffer = "";
@@ -131,20 +195,39 @@ export class MoltZapService {
131
195
  for (const line of lines) {
132
196
  if (!line.trim())
133
197
  continue;
134
- 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));
135
202
  }
136
203
  });
137
204
  });
138
- 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
+ });
139
216
  // Symlink default path to this instance for CLI discovery
140
217
  try {
141
218
  fs.unlinkSync(MoltZapService.SOCKET_PATH);
142
219
  }
143
- 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
+ }
144
225
  try {
145
226
  fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
146
227
  }
147
- catch { }
228
+ catch (err) {
229
+ this.opts.logger?.warn("symlink default socket failed", err);
230
+ }
148
231
  }
149
232
  stopSocketServer() {
150
233
  this.socketServer?.close();
@@ -154,193 +237,221 @@ export class MoltZapService {
154
237
  try {
155
238
  fs.unlinkSync(sockPath);
156
239
  }
157
- catch { }
158
- // 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.
159
244
  try {
160
245
  const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
161
246
  if (target === sockPath)
162
247
  fs.unlinkSync(MoltZapService.SOCKET_PATH);
163
248
  }
164
- catch { }
249
+ catch (err) {
250
+ this.opts.logger?.info("cleanup default symlink", err);
251
+ }
165
252
  }
166
- async handleSocketLine(line, conn) {
167
- try {
168
- 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
+ }
169
270
  if (typeof req.method !== "string" || !req.method) {
170
- 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"));
171
274
  }
172
275
  const params = req.params != null && typeof req.params === "object"
173
- ? req.params
276
+ ? req.params // #ignore-sloppy-code[record-cast]: req.params is untyped JSON from socket
174
277
  : {};
175
- const result = await this.handleSocketRequest(req.method, params);
176
- conn.write(JSON.stringify({ result }) + "\n");
177
- }
178
- catch (err) {
179
- conn.write(JSON.stringify({
180
- error: err instanceof Error ? err.message : String(err),
181
- }) + "\n");
182
- }
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
+ });
183
308
  }
184
- async handleSocketRequest(method, params) {
185
- switch (method) {
186
- case "ping":
187
- return { ok: true, agentId: this.ownAgentId };
188
- 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);
189
359
  return {
190
- agentId: this.ownAgentId,
191
- connected: this._connected,
192
- 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,
193
367
  };
194
- case "history": {
195
- if (typeof params.conversationId !== "string" ||
196
- !params.conversationId) {
197
- throw new Error("conversationId is required and must be a string");
198
- }
199
- if (params.limit !== undefined && typeof params.limit !== "number") {
200
- throw new Error("limit must be a number");
201
- }
202
- const convId = params.conversationId;
203
- const limit = params.limit ?? 10;
204
- const sessionKey = params.sessionKey;
205
- const afterSeq = params.afterSeq;
206
- const beforeSeq = params.beforeSeq;
207
- const result = (await this.sendRpc("messages/list", {
208
- conversationId: convId,
209
- limit,
210
- ...(afterSeq !== undefined ? { afterSeq } : {}),
211
- ...(beforeSeq !== undefined ? { beforeSeq } : {}),
212
- }));
213
- // Resolve agent names (batch) + fetch conversation metadata (concurrent)
214
- const unknownAgentIds = [
215
- ...new Set(result.messages
216
- .filter((m) => m.sender.type === "agent")
217
- .map((m) => m.sender.id)),
218
- ].filter((id) => !this.agentNames.has(id));
219
- const [, convMeta] = await Promise.all([
220
- unknownAgentIds.length > 0
221
- ? this.sendRpc("agents/lookup", { agentIds: unknownAgentIds }).then((res) => {
222
- for (const a of res.agents) {
223
- this.agentNames.set(a.id, a.name);
224
- }
225
- })
226
- : Promise.resolve(),
227
- this.sendRpc("conversations/get", { conversationId: convId })
228
- .then((res) => res
229
- .conversation)
230
- .catch(() => undefined),
231
- ]);
232
- // Determine what's "new" using lastRead (not lastNotified).
233
- // lastNotified is advanced by getContext() (system-reminder).
234
- // lastRead is advanced here when the agent explicitly reads history.
235
- // This means messages stay "new" until the agent reads them via history,
236
- // even if the system-reminder already notified about them.
237
- const readMarkers = sessionKey
238
- ? (this.lastRead.get(sessionKey) ?? new Map())
239
- : null;
240
- const lastReadSeq = readMarkers?.get(convId) ?? 0;
241
- const messages = result.messages.map((m) => {
242
- const text = m.parts.map(renderPart).join(" ");
243
- return {
244
- seq: m.seq,
245
- senderId: m.sender.id,
246
- senderName: m.sender.id === this.ownAgentId
247
- ? "you"
248
- : (this.agentNames.get(m.sender.id) ?? m.sender.id),
249
- isOwn: m.sender.id === this.ownAgentId,
250
- text,
251
- createdAt: m.createdAt,
252
- isNew: sessionKey ? m.seq > lastReadSeq : false,
253
- };
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));
254
380
  });
255
- // Advance lastRead to the highest seq in the returned page.
256
- // The agent has seen these messages; older ones (if paginated) remain
257
- // accessible via afterSeq/beforeSeq cursors.
258
- if (sessionKey && result.messages.length > 0) {
259
- const maxSeq = Math.max(...result.messages.map((m) => m.seq));
260
- if (!this.lastRead.has(sessionKey)) {
261
- this.lastRead.set(sessionKey, new Map());
262
- }
263
- const current = this.lastRead.get(sessionKey).get(convId) ?? 0;
264
- if (maxSeq > current) {
265
- this.lastRead.get(sessionKey).set(convId, maxSeq);
266
- }
267
- }
268
- return {
269
- messages,
270
- hasMore: result.hasMore,
271
- conversationMeta: convMeta,
272
- newCount: messages.filter((m) => m.isNew).length,
273
- };
274
381
  }
275
- default:
276
- // Passthrough: forward any other method to the MoltZap server via RPC
277
- return this.sendRpc(method, params);
278
- }
382
+ return {
383
+ messages,
384
+ hasMore: result.hasMore,
385
+ conversationMeta: convMeta,
386
+ newCount: messages.filter((m) => m.isNew).length,
387
+ };
388
+ });
279
389
  }
280
390
  // --- Conversations ---
281
391
  getConversation(convId) {
282
- return this.conversations.get(convId);
392
+ return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
283
393
  }
284
394
  getConversations() {
285
- return [...this.conversations.values()];
395
+ return [...HashMap.values(snapshot(this.conversationsRef))];
286
396
  }
287
397
  // --- Messages ---
288
- getHistory(convId, limit = 20) {
289
- const msgs = this.messages.get(convId) ?? [];
290
- return msgs.slice(-limit);
398
+ getHistory(convId, limit) {
399
+ const msgs = getOr(snapshot(this.messagesRef), convId, () => []);
400
+ return limit ? msgs.slice(-limit) : [...msgs];
291
401
  }
292
402
  // --- Agent Names ---
293
403
  getAgentName(agentId) {
294
- return this.agentNames.get(agentId);
295
- }
296
- async resolveAgentName(agentId) {
297
- const cached = this.agentNames.get(agentId);
298
- if (cached)
299
- return cached;
300
- const pending = this.pendingNameLookups.get(agentId);
301
- if (pending)
302
- return pending;
303
- const promise = (async () => {
304
- const result = (await this.sendRpc("agents/lookup", {
305
- agentIds: [agentId],
306
- }));
307
- const agent = result.agents[0];
308
- if (agent) {
309
- this.agentNames.set(agentId, agent.name);
310
- return agent.name;
311
- }
312
- return agentId;
313
- })();
314
- this.pendingNameLookups.set(agentId, promise);
315
- try {
316
- return await promise;
317
- }
318
- finally {
319
- this.pendingNameLookups.delete(agentId);
320
- }
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
+ });
321
424
  }
322
425
  // --- Messaging ---
323
- async send(convId, text, opts) {
324
- await this.sendRpc("messages/send", {
426
+ send(convId, text, opts) {
427
+ return Effect.asVoid(this.sendRpc("messages/send", {
325
428
  conversationId: convId,
326
429
  parts: [{ type: "text", text }],
327
430
  ...(opts?.replyTo ? { replyToId: opts.replyTo } : {}),
328
- });
431
+ }));
329
432
  }
330
- async sendToAgent(agentName, text, opts) {
331
- let conversationId = this.agentConversationCache.get(agentName);
332
- if (!conversationId) {
333
- const lookupResult = (await this.sendRpc("agents/lookupByName", {
334
- name: agentName,
335
- }));
336
- const createResult = (await this.sendRpc("conversations/create", {
337
- type: "dm",
338
- participants: [{ type: "agent", id: lookupResult.agent.id }],
339
- }));
340
- conversationId = createResult.conversation.id;
341
- this.agentConversationCache.set(agentName, conversationId);
342
- }
343
- 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
+ });
344
455
  }
345
456
  // --- Cross-Conversation Context ---
346
457
  /**
@@ -366,44 +477,108 @@ export class MoltZapService {
366
477
  peekContextEntries(currentConvId, opts) {
367
478
  const maxConvs = opts?.maxConversations ?? 5;
368
479
  const maxMsgsPerConv = opts?.maxMessagesPerConv ?? 3;
369
- 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);
370
496
  const entries = [];
371
497
  const pendingAdvances = [];
372
- for (const [convId, msgs] of this.messages) {
373
- if (convId === currentConvId || msgs.length === 0)
374
- continue;
375
- if (entries.length >= maxConvs)
376
- break;
377
- const lastSeenSeq = viewMarkers.get(convId) ?? 0;
378
- const newMsgs = msgs.filter((m) => m.seq > lastSeenSeq);
379
- if (newMsgs.length === 0)
380
- continue;
498
+ for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
381
499
  const reportable = newMsgs.slice(-maxMsgsPerConv);
382
500
  const last = reportable[reportable.length - 1];
383
- const senderName = this.resolveSenderLabel(last.sender.id);
501
+ const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
384
502
  const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) / 60_000));
385
- const text = last.parts.map(renderPart).join(" ");
386
503
  entries.push({
387
504
  conversationId: convId,
388
- conversationName: this.conversations.get(convId)?.name,
505
+ conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
389
506
  senderName,
390
- text,
507
+ text: last.parts.map(renderPart).join(" "),
391
508
  minutesAgo,
392
509
  count: reportable.length,
393
510
  });
394
- pendingAdvances.push([convId, last.seq]);
511
+ pendingAdvances.push([convId, last.id]);
395
512
  }
396
- const commit = () => {
397
- for (const [convId, seq] of pendingAdvances) {
398
- viewMarkers.set(convId, seq);
513
+ return {
514
+ entries,
515
+ commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
516
+ };
517
+ }
518
+ /**
519
+ * Return all new messages from all other conversations as full transcripts,
520
+ * sorted chronologically. Uses the same lastNotified markers as
521
+ * peekContextEntries. Call commit() to advance markers.
522
+ */
523
+ peekFullMessages(currentConvId) {
524
+ const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
525
+ const allMessages = [];
526
+ const pendingAdvances = [];
527
+ for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
528
+ const convName = Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name;
529
+ for (const m of newMsgs) {
530
+ allMessages.push({
531
+ conversationId: convId,
532
+ conversationName: convName,
533
+ senderName: getOr(agentNamesMap, m.senderId, () => m.senderId),
534
+ senderId: m.senderId,
535
+ text: m.parts.map(renderPart).join(" "),
536
+ timestamp: m.createdAt,
537
+ });
399
538
  }
400
- this.lastNotified.set(currentConvId, viewMarkers);
539
+ pendingAdvances.push([convId, newMsgs[newMsgs.length - 1].id]);
540
+ }
541
+ allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
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()),
401
554
  };
402
- return { entries, commit };
403
555
  }
404
- on(event,
405
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
406
- 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) {
407
582
  switch (event) {
408
583
  case "message":
409
584
  this.messageHandlers.push(handler);
@@ -417,13 +592,22 @@ export class MoltZapService {
417
592
  case "reconnect":
418
593
  this.reconnectHandlers.push(handler);
419
594
  break;
595
+ case "permissionRequired":
596
+ this.permissionRequiredHandlers.push(handler);
597
+ break;
420
598
  }
421
599
  }
422
600
  // --- RPC passthrough ---
423
- async sendRpc(method, params) {
424
- if (!this.client)
425
- throw new Error("Not connected");
426
- 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));
427
611
  }
428
612
  // --- Internals ---
429
613
  /**
@@ -431,85 +615,92 @@ export class MoltZapService {
431
615
  * into the local cache. Called on ConversationCreated events because the
432
616
  * event schema omits the participants list — see protocol/events.ts.
433
617
  */
434
- async refreshConversationParticipants(conversationId) {
435
- try {
436
- const res = (await this.sendRpc("conversations/get", {
437
- conversationId,
438
- }));
439
- const existing = this.conversations.get(conversationId);
440
- this.conversations.set(conversationId, {
441
- id: res.conversation.id,
442
- type: res.conversation.type,
443
- name: res.conversation.name,
444
- participants: res.participants.map((p) => `${p.participant.type}:${p.participant.id}`),
445
- });
446
- }
447
- catch {
448
- // Best-effort refresh. Leave the existing entry in place on failure.
449
- }
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));
450
631
  }
451
632
  populateFromHello(hello) {
452
633
  if (!hello.conversations)
453
634
  return;
454
- for (const conv of hello.conversations) {
455
- this.conversations.set(conv.id, {
456
- id: conv.id,
457
- type: conv.type,
458
- name: conv.name,
459
- participants: (conv.participants ?? []).map((p) => `${p.type}:${p.id}`),
460
- });
461
- }
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
+ }));
462
648
  }
463
649
  handleEvent(event) {
464
- for (const h of this.rawEventHandlers)
465
- 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.
466
654
  switch (event.event) {
467
655
  case EventNames.MessageReceived: {
468
656
  const msg = event.data.message;
469
657
  this.storeMessage(msg);
470
- // Resolve sender name in background
471
- if (msg.sender.type === "agent" &&
472
- !this.agentNames.has(msg.sender.id)) {
473
- void this.resolveAgentName(msg.sender.id);
474
- }
475
- // Emit to external handlers (only non-own messages)
476
- if (msg.sender.id !== this.ownAgentId) {
477
- for (const h of this.messageHandlers)
478
- 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);
479
663
  }
480
664
  break;
481
665
  }
666
+ case EventNames.PermissionsRequired: {
667
+ fanout(this.permissionRequiredHandlers, event.data, this.opts.logger);
668
+ break;
669
+ }
482
670
  case EventNames.ConversationCreated:
483
671
  case EventNames.ConversationUpdated: {
484
- const data = event.data;
485
- const existing = this.conversations.get(data.conversation.id);
486
- this.conversations.set(data.conversation.id, {
487
- id: data.conversation.id,
488
- type: data.conversation.type,
489
- name: data.conversation.name,
490
- participants: existing?.participants ?? [],
491
- });
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
+ }));
492
682
  // The ConversationCreated event schema doesn't carry participants
493
683
  // (protocol/schema/events.ts: ConversationSchema is id/type/name/
494
684
  // createdBy/timestamps only). Fetch full details asynchronously so
495
685
  // downstream code that reads getConversation(id).participants sees
496
686
  // a populated list within a round-trip of the event.
497
687
  if (event.event === EventNames.ConversationCreated) {
498
- void this.refreshConversationParticipants(data.conversation.id);
688
+ // Fire-and-forget: refreshConversationParticipants never fails.
689
+ Effect.runFork(this.refreshConversationParticipants(conversation.id));
499
690
  }
500
691
  break;
501
692
  }
502
693
  }
503
694
  }
504
695
  storeMessage(msg) {
505
- const buf = this.messages.get(msg.conversationId) ?? [];
506
- buf.push(msg);
507
- if (buf.length > MAX_MESSAGES_PER_CONV)
508
- buf.shift();
509
- this.messages.set(msg.conversationId, buf);
510
- }
511
- resolveSenderLabel(senderId) {
512
- 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
+ }));
513
704
  }
514
705
  }
515
706
  //# sourceMappingURL=service.js.map