@moltzap/client 2026.511.1 → 2026.518.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 (194) hide show
  1. package/dist/auth.d.ts +12 -6
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +67 -29
  4. package/dist/auth.js.map +1 -1
  5. package/dist/auth.test.js +113 -76
  6. package/dist/auth.test.js.map +1 -1
  7. package/dist/channel-core-context.test.d.ts +2 -0
  8. package/dist/channel-core-context.test.d.ts.map +1 -0
  9. package/dist/channel-core-context.test.js +334 -0
  10. package/dist/channel-core-context.test.js.map +1 -0
  11. package/dist/channel-core-dispatch.test.d.ts +2 -0
  12. package/dist/channel-core-dispatch.test.d.ts.map +1 -0
  13. package/dist/channel-core-dispatch.test.js +550 -0
  14. package/dist/channel-core-dispatch.test.js.map +1 -0
  15. package/dist/channel-core-enrichment.d.ts +8 -0
  16. package/dist/channel-core-enrichment.d.ts.map +1 -0
  17. package/dist/channel-core-enrichment.js +134 -0
  18. package/dist/channel-core-enrichment.js.map +1 -0
  19. package/dist/channel-core-errors.d.ts +20 -0
  20. package/dist/channel-core-errors.d.ts.map +1 -0
  21. package/dist/channel-core-errors.js +12 -0
  22. package/dist/channel-core-errors.js.map +1 -0
  23. package/dist/channel-core-test-support.d.ts +48 -0
  24. package/dist/channel-core-test-support.d.ts.map +1 -0
  25. package/dist/channel-core-test-support.js +111 -0
  26. package/dist/channel-core-test-support.js.map +1 -0
  27. package/dist/channel-core.d.ts +30 -7
  28. package/dist/channel-core.d.ts.map +1 -1
  29. package/dist/channel-core.js +214 -237
  30. package/dist/channel-core.js.map +1 -1
  31. package/dist/channel-core.test.js +216 -1082
  32. package/dist/channel-core.test.js.map +1 -1
  33. package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
  34. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
  35. package/dist/cli/commands/conversations-v2.test.js +64 -54
  36. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  37. package/dist/cli/commands/conversations.d.ts +5 -5
  38. package/dist/cli/commands/conversations.d.ts.map +1 -1
  39. package/dist/cli/commands/conversations.js +43 -36
  40. package/dist/cli/commands/conversations.js.map +1 -1
  41. package/dist/cli/commands/messages.d.ts +4 -4
  42. package/dist/cli/commands/messages.d.ts.map +1 -1
  43. package/dist/cli/commands/messages.js +4 -4
  44. package/dist/cli/commands/messages.js.map +1 -1
  45. package/dist/cli/commands/messages.test.js +61 -49
  46. package/dist/cli/commands/messages.test.js.map +1 -1
  47. package/dist/cli/commands/ping.d.ts +0 -9
  48. package/dist/cli/commands/ping.d.ts.map +1 -1
  49. package/dist/cli/commands/ping.js +1 -1
  50. package/dist/cli/commands/ping.js.map +1 -1
  51. package/dist/cli/commands/presence.d.ts +1 -1
  52. package/dist/cli/commands/presence.js +1 -1
  53. package/dist/cli/commands/register.d.ts +2 -2
  54. package/dist/cli/commands/register.d.ts.map +1 -1
  55. package/dist/cli/commands/register.js +98 -77
  56. package/dist/cli/commands/register.js.map +1 -1
  57. package/dist/cli/commands/register.test.js +63 -39
  58. package/dist/cli/commands/register.test.js.map +1 -1
  59. package/dist/cli/commands/send.d.ts +6 -6
  60. package/dist/cli/commands/send.js +6 -6
  61. package/dist/cli/commands/send.test.js +32 -28
  62. package/dist/cli/commands/send.test.js.map +1 -1
  63. package/dist/cli/commands/status.d.ts.map +1 -1
  64. package/dist/cli/commands/status.js +3 -4
  65. package/dist/cli/commands/status.js.map +1 -1
  66. package/dist/cli/config.d.ts +0 -2
  67. package/dist/cli/config.d.ts.map +1 -1
  68. package/dist/cli/config.js +86 -76
  69. package/dist/cli/config.js.map +1 -1
  70. package/dist/cli/config.test.js +80 -42
  71. package/dist/cli/config.test.js.map +1 -1
  72. package/dist/cli/index.d.ts +1 -16
  73. package/dist/cli/index.d.ts.map +1 -1
  74. package/dist/cli/index.js +50 -113
  75. package/dist/cli/index.js.map +1 -1
  76. package/dist/cli/process-argv.d.ts +2 -0
  77. package/dist/cli/process-argv.d.ts.map +1 -0
  78. package/dist/cli/process-argv.js +2 -0
  79. package/dist/cli/process-argv.js.map +1 -0
  80. package/dist/cli/profile.d.ts +3 -10
  81. package/dist/cli/profile.d.ts.map +1 -1
  82. package/dist/cli/profile.js +105 -104
  83. package/dist/cli/profile.js.map +1 -1
  84. package/dist/cli/profile.test.js +338 -232
  85. package/dist/cli/profile.test.js.map +1 -1
  86. package/dist/cli/runtime.d.ts +5 -17
  87. package/dist/cli/runtime.d.ts.map +1 -1
  88. package/dist/cli/runtime.js +6 -59
  89. package/dist/cli/runtime.js.map +1 -1
  90. package/dist/cli/socket-client.d.ts +10 -15
  91. package/dist/cli/socket-client.d.ts.map +1 -1
  92. package/dist/cli/socket-client.js +74 -89
  93. package/dist/cli/socket-client.js.map +1 -1
  94. package/dist/cli/transport.d.ts +9 -5
  95. package/dist/cli/transport.d.ts.map +1 -1
  96. package/dist/cli/transport.js +45 -42
  97. package/dist/cli/transport.js.map +1 -1
  98. package/dist/cli/transport.test.js +227 -133
  99. package/dist/cli/transport.test.js.map +1 -1
  100. package/dist/index.d.ts +4 -1
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/local-daemon-rpc.d.ts +13 -0
  105. package/dist/local-daemon-rpc.d.ts.map +1 -0
  106. package/dist/local-daemon-rpc.js +17 -0
  107. package/dist/local-daemon-rpc.js.map +1 -0
  108. package/dist/local-paths.d.ts +7 -0
  109. package/dist/local-paths.d.ts.map +1 -0
  110. package/dist/local-paths.js +20 -0
  111. package/dist/local-paths.js.map +1 -0
  112. package/dist/runtime/close-info.d.ts +3 -12
  113. package/dist/runtime/close-info.d.ts.map +1 -1
  114. package/dist/runtime/close-info.js +28 -39
  115. package/dist/runtime/close-info.js.map +1 -1
  116. package/dist/runtime/close-info.test.js +77 -70
  117. package/dist/runtime/close-info.test.js.map +1 -1
  118. package/dist/runtime/errors.test.js +42 -28
  119. package/dist/runtime/errors.test.js.map +1 -1
  120. package/dist/runtime/frame.d.ts +4 -31
  121. package/dist/runtime/frame.d.ts.map +1 -1
  122. package/dist/runtime/frame.js +7 -116
  123. package/dist/runtime/frame.js.map +1 -1
  124. package/dist/runtime/frame.test.js +41 -54
  125. package/dist/runtime/frame.test.js.map +1 -1
  126. package/dist/runtime/index.d.ts +7 -0
  127. package/dist/runtime/index.d.ts.map +1 -1
  128. package/dist/runtime/index.js +7 -11
  129. package/dist/runtime/index.js.map +1 -1
  130. package/dist/runtime/local-history.d.ts +67 -0
  131. package/dist/runtime/local-history.d.ts.map +1 -0
  132. package/dist/runtime/local-history.js +77 -0
  133. package/dist/runtime/local-history.js.map +1 -0
  134. package/dist/runtime/local-service-commands.d.ts +19 -0
  135. package/dist/runtime/local-service-commands.d.ts.map +1 -1
  136. package/dist/runtime/local-service-commands.js +23 -0
  137. package/dist/runtime/local-service-commands.js.map +1 -1
  138. package/dist/runtime/local-socket-server.d.ts +20 -0
  139. package/dist/runtime/local-socket-server.d.ts.map +1 -0
  140. package/dist/runtime/local-socket-server.js +94 -0
  141. package/dist/runtime/local-socket-server.js.map +1 -0
  142. package/dist/runtime/refs.d.ts +1 -1
  143. package/dist/runtime/refs.js +1 -1
  144. package/dist/runtime/runtime.test.js +29 -21
  145. package/dist/runtime/runtime.test.js.map +1 -1
  146. package/dist/runtime/service-helpers.d.ts +32 -0
  147. package/dist/runtime/service-helpers.d.ts.map +1 -0
  148. package/dist/runtime/service-helpers.js +85 -0
  149. package/dist/runtime/service-helpers.js.map +1 -0
  150. package/dist/runtime/subscribers.d.ts +13 -17
  151. package/dist/runtime/subscribers.d.ts.map +1 -1
  152. package/dist/runtime/subscribers.js +37 -32
  153. package/dist/runtime/subscribers.js.map +1 -1
  154. package/dist/runtime/subscribers.test.js +139 -131
  155. package/dist/runtime/subscribers.test.js.map +1 -1
  156. package/dist/service-socket-path.test.d.ts +2 -0
  157. package/dist/service-socket-path.test.d.ts.map +1 -0
  158. package/dist/service-socket-path.test.js +87 -0
  159. package/dist/service-socket-path.test.js.map +1 -0
  160. package/dist/service.d.ts +23 -20
  161. package/dist/service.d.ts.map +1 -1
  162. package/dist/service.js +192 -340
  163. package/dist/service.js.map +1 -1
  164. package/dist/service.test.js +652 -653
  165. package/dist/service.test.js.map +1 -1
  166. package/dist/test/index.d.ts +30 -14
  167. package/dist/test/index.d.ts.map +1 -1
  168. package/dist/test/index.js +25 -21
  169. package/dist/test/index.js.map +1 -1
  170. package/dist/test-utils/channel-service-fixture.d.ts +1 -0
  171. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  172. package/dist/test-utils/channel-service-fixture.js +139 -98
  173. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  174. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  175. package/dist/test-utils/conformance-adapter.js +145 -124
  176. package/dist/test-utils/conformance-adapter.js.map +1 -1
  177. package/dist/test-utils/fake-service.d.ts.map +1 -1
  178. package/dist/test-utils/fake-service.js +3 -3
  179. package/dist/test-utils/fake-service.js.map +1 -1
  180. package/dist/test-utils/index.d.ts +25 -0
  181. package/dist/test-utils/index.d.ts.map +1 -1
  182. package/dist/test-utils/index.js +25 -8
  183. package/dist/test-utils/index.js.map +1 -1
  184. package/dist/ws-client-test-support.d.ts +180 -0
  185. package/dist/ws-client-test-support.d.ts.map +1 -0
  186. package/dist/ws-client-test-support.js +399 -0
  187. package/dist/ws-client-test-support.js.map +1 -0
  188. package/dist/ws-client.d.ts +59 -32
  189. package/dist/ws-client.d.ts.map +1 -1
  190. package/dist/ws-client.js +272 -321
  191. package/dist/ws-client.js.map +1 -1
  192. package/dist/ws-client.test.js +388 -800
  193. package/dist/ws-client.test.js.map +1 -1
  194. package/package.json +14 -10
package/dist/service.js CHANGED
@@ -1,50 +1,39 @@
1
- import * as net from "node:net";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import * as os from "node:os";
1
+ import { FileSystem, Path } from "@effect/platform";
2
+ import { NodeContext } from "@effect/platform-node";
5
3
  import { AgentsLookup, AgentsLookupByName, ConversationsCreate, ConversationsGet, DispatchRequest, DispatchRelease, DispatchesConsumed, DispatchesExpired, DispatchesGet, MessageReceivedNotificationDefinition, ConversationCreatedNotificationDefinition, ConversationUpdatedNotificationDefinition, ConversationArchivedNotificationDefinition, ConversationUnarchivedNotificationDefinition, ConversationArchivedError, MessagesList, MessagesSend, NotConnectedError, rpcMethods, RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
6
- import { Config, ConfigProvider, Data, Effect, HashMap, Match, Option, Ref, } from "effect";
7
- import { MoltZapWsClient, } from "./ws-client.js";
4
+ import { Config, ConfigProvider, Effect, HashMap, Option, Ref, Scope, } from "effect";
5
+ import { MoltZapWsClient } from "./ws-client.js";
8
6
  import { AgentNotFoundError } from "./runtime/errors.js";
9
7
  import { getOr, snapshot } from "./runtime/refs.js";
10
8
  import { LocalServiceCommands } from "./runtime/local-service-commands.js";
11
- const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ import { getMoltZapAgentServiceSocketPath, getMoltZapServiceSocketPath, } from "./local-paths.js";
10
+ import { startLocalSocketServer, stopLocalSocketServer, } from "./runtime/local-socket-server.js";
11
+ import { buildContextEntries, makeContextCandidate, newMessagesForConversation, notificationTraceRecord, renderPart, ServiceInputError, } from "./runtime/service-helpers.js";
12
+ import { decodeHistoryRequest, formatHistoryMessage, lastReadIdsForSession, } from "./runtime/local-history.js";
12
13
  const CROSS_CONTEXT_TEXT_LIMIT = 120;
13
- const DEFAULT_HISTORY_LIMIT = 10;
14
14
  const DEFAULT_MAX_CONTEXT_CONVERSATIONS = 5;
15
15
  const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 3;
16
16
  const HISTORY_LOOKUP_CONCURRENCY = 2;
17
- const MILLISECONDS_PER_MINUTE = 60_000;
18
- const SOCKET_FILE_MODE = 0o600;
19
- class ServiceInputError extends Data.TaggedError("ServiceInputError") {
20
- }
21
17
  const ClientEventLogDir = Config.option(Config.string("MOLTZAP_CLIENT_EVENT_LOG_DIR"));
22
18
  const getClientEventLogDir = () => Option.getOrUndefined(Effect.runSync(ClientEventLogDir.pipe(Effect.withConfigProvider(ConfigProvider.fromEnv()))));
23
19
  function appendClientEventTrace(record) {
24
20
  const dir = getClientEventLogDir();
25
21
  if (!dir)
26
- return;
27
- try {
28
- fs.mkdirSync(dir, { recursive: true });
22
+ return Effect.void;
23
+ return Effect.gen(function* () {
24
+ const fileSystem = yield* FileSystem.FileSystem;
25
+ const path = yield* Path.Path;
29
26
  const agentId = typeof record["agentId"] === "string" ? record["agentId"] : "unknown";
30
27
  const safeAgentId = /^[A-Za-z0-9_-]+$/.test(agentId) ? agentId : "unknown";
31
- fs.appendFileSync(path.join(dir, `client-events-${safeAgentId}.jsonl`), JSON.stringify(record) + "\n");
32
- }
33
- catch (err) {
34
- try {
35
- process.stderr.write(`moltzap client event trace write failed: ${err instanceof Error ? err.message : String(err)}\n`);
36
- }
37
- catch (stderrErr) {
38
- void stderrErr;
39
- }
40
- }
28
+ yield* Effect.zipRight(fileSystem.makeDirectory(dir, { recursive: true }), fileSystem.writeFileString(path.join(dir, `client-events-${safeAgentId}.jsonl`), JSON.stringify(record) + "\n", { flag: "a" }));
29
+ }).pipe(Effect.withSpan("appendClientEventTrace"), Effect.provide(NodeContext.layer), Effect.catchAll((err) => Effect.logWarning("moltzap client event trace write failed", err)));
41
30
  }
42
- /** Escape `<`, `>`, `&` so sender content can't escape a `<system-reminder>` block. */
31
+ /** Escape `&lt;`, `>`, `&amp;` so sender content can't escape a `&lt;system-reminder>` block. */
43
32
  export function sanitizeForSystemReminder(s) {
44
33
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
45
34
  }
46
35
  /**
47
- * Format CrossConversationEntry[] as a `<system-reminder>` block. Adapters
36
+ * Format CrossConversationEntry[] as a `&lt;system-reminder>` block. Adapters
48
37
  * that inline context into prompt text (nanoclaw) and `MoltZapService.getContext`
49
38
  * share this formatter so sanitization and line shape stay in one place.
50
39
  */
@@ -63,11 +52,6 @@ export function formatCrossConversationBlock(entries, opts) {
63
52
  "</system-reminder>",
64
53
  ].join("\n");
65
54
  }
66
- const renderPart = Match.type().pipe(Match.discriminatorsExhaustive("type")({
67
- text: (t) => t.text,
68
- image: () => "[image]",
69
- file: (f) => `[file: ${f.name}]`,
70
- }));
71
55
  /**
72
56
  * Per-conversation message cap. Older messages are evicted FIFO; the
73
57
  * on-disk history remains the source of truth. Sized for typical CLI
@@ -83,15 +67,15 @@ const MAX_MESSAGES_PER_CONV = 1000;
83
67
  const DEDUP_WINDOW_PER_CONV = 1000;
84
68
  /**
85
69
  * Invoke every handler with `arg`, isolating throws so one bad handler
86
- * doesn't abort the remaining fanout. Logs via the optional client logger.
70
+ * doesn't abort the remaining fanout.
87
71
  */
88
- function fanout(handlers, arg, logger) {
72
+ function fanout(handlers, arg) {
89
73
  for (const h of handlers) {
90
74
  try {
91
75
  h(arg);
92
76
  }
93
77
  catch (err) {
94
- logger?.error("notification handler threw", err);
78
+ Effect.runFork(Effect.logError("notification handler threw", err));
95
79
  }
96
80
  }
97
81
  }
@@ -138,6 +122,40 @@ export class MoltZapService {
138
122
  dispatchesConsumed: [],
139
123
  dispatchesExpired: [],
140
124
  };
125
+ notificationDispatchers = new Map([
126
+ [
127
+ MessageReceivedNotificationDefinition,
128
+ (notification) => this.handleMessageReceivedNotification(notification.params),
129
+ ],
130
+ [
131
+ ConversationCreatedNotificationDefinition,
132
+ (notification) => this.handleConversationCreatedNotification(notification.params),
133
+ ],
134
+ [
135
+ ConversationUpdatedNotificationDefinition,
136
+ (notification) => this.handleConversationUpdatedNotification(notification.params),
137
+ ],
138
+ [
139
+ ConversationArchivedNotificationDefinition,
140
+ (notification) => this.handleConversationArchivedNotification(notification.params),
141
+ ],
142
+ [
143
+ ConversationUnarchivedNotificationDefinition,
144
+ (notification) => this.handleConversationUnarchivedNotification(notification.params),
145
+ ],
146
+ [
147
+ DispatchRelease,
148
+ (notification) => fanout(this.handlers.dispatchRelease, notification.params),
149
+ ],
150
+ [
151
+ DispatchesConsumed,
152
+ (notification) => fanout(this.handlers.dispatchesConsumed, notification.params),
153
+ ],
154
+ [
155
+ DispatchesExpired,
156
+ (notification) => fanout(this.handlers.dispatchesExpired, notification.params),
157
+ ],
158
+ ]);
141
159
  _ownAgentId;
142
160
  constructor(opts) {
143
161
  this.opts = opts;
@@ -154,18 +172,16 @@ export class MoltZapService {
154
172
  this.client = new MoltZapWsClient({
155
173
  serverUrl: this.opts.serverUrl,
156
174
  agentKey: this.opts.agentKey,
157
- logger: this.opts.logger,
158
175
  // Spec #222 OQ-6: arg required. The body doesn't branch on
159
176
  // close metadata today; signature kept explicit so a future
160
177
  // disconnect-handler chain can plumb code/reason through.
161
- onDisconnect: (close) => {
162
- void close;
178
+ onDisconnect: () => {
163
179
  this._connected = false;
164
- fanout(this.handlers.disconnect, undefined, this.opts.logger);
180
+ fanout(this.handlers.disconnect, undefined);
165
181
  },
166
182
  onReconnect: (helloOk) => {
167
183
  this._connected = true;
168
- fanout(this.handlers.reconnect, helloOk, this.opts.logger);
184
+ fanout(this.handlers.reconnect, helloOk);
169
185
  },
170
186
  });
171
187
  // Spec #222 OQ-4: per-notification callback is replaced by subscriptions.
@@ -182,15 +198,15 @@ export class MoltZapService {
182
198
  }
183
199
  /**
184
200
  * Tear down the service. `close()` is sync because it fans out to the
185
- * socket server, Refs, and the ws-client — the ws-client's own close
186
- * Effect is run via `Effect.runSync` here so the caller gets an immediate
187
- * shutdown the way existing code expects.
201
+ * socket server, Refs, and the ws-client. Effectful network/filesystem
202
+ * cleanup is forked at the edge so existing callers still get immediate
203
+ * shutdown.
188
204
  */
189
205
  close() {
190
206
  this._connected = false;
191
- this.stopSocketServer();
207
+ Effect.runFork(this.stopSocketServer());
192
208
  if (this.client) {
193
- void Effect.runPromise(this.client.close());
209
+ Effect.runFork(this.client.close());
194
210
  }
195
211
  this.client = null;
196
212
  Effect.runSync(Effect.all([
@@ -209,10 +225,10 @@ export class MoltZapService {
209
225
  // service instance.
210
226
  }
211
227
  // --- Socket Server ---
212
- socketServer = null;
228
+ socketServerScope = null;
213
229
  activeSocketPath = null;
214
230
  /** Default socket path for CLI discovery. Per-instance path uses agentId. */
215
- static SOCKET_PATH = path.join(os.homedir(), ".moltzap", "service.sock");
231
+ static SOCKET_PATH = getMoltZapServiceSocketPath();
216
232
  /**
217
233
  * `agentId` is a server-assigned string. Treat it as untrusted: if a
218
234
  * compromised or malicious server returns an id containing `..` or a
@@ -225,127 +241,39 @@ export class MoltZapService {
225
241
  /** Per-instance socket path based on connected agentId. */
226
242
  get socketPath() {
227
243
  const id = MoltZapService.safeAgentIdSegment(this.ownAgentId ?? "default");
228
- return path.join(os.homedir(), ".moltzap", `service-${id}.sock`);
244
+ return getMoltZapAgentServiceSocketPath(id);
229
245
  }
230
246
  startSocketServer() {
231
- const sockPath = this.socketPath;
232
- this.activeSocketPath = sockPath;
233
- try {
234
- fs.unlinkSync(sockPath);
235
- }
236
- catch (err) {
237
- // ENOENT is the normal case (no stale socket); log everything
238
- // else (permission denied, EACCES) so operators can diagnose.
239
- const code = err.code;
240
- if (code !== "ENOENT") {
241
- this.opts.logger?.warn("unlink existing socket failed", err);
242
- }
243
- }
244
- fs.mkdirSync(path.dirname(sockPath), { recursive: true });
245
- this.socketServer = net.createServer((conn) => {
246
- let buffer = "";
247
- conn.on("data", (chunk) => {
248
- buffer += chunk.toString();
249
- const lines = buffer.split("\n");
250
- buffer = lines.pop();
251
- for (const line of lines) {
252
- if (!line.trim())
253
- continue;
254
- // Fork the line-handler Effect onto the Effect runtime. Each line
255
- // dispatches independently; the fiber runs until it writes a
256
- // response or error frame back onto the socket.
257
- Effect.runFork(this.handleSocketLineEffect(line, conn));
258
- }
247
+ return Effect.gen(this, function* () {
248
+ const previous = this.resetSocketServerState();
249
+ yield* stopLocalSocketServer({
250
+ socketScope: previous.socketScope,
251
+ socketPath: previous.sockPath,
252
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
259
253
  });
260
- });
261
- this.socketServer.listen(sockPath, () => {
262
- // Owner-only permissions: the socket exposes an RPC passthrough that
263
- // impersonates this agent to the server, so other local users on the
264
- // host must not be able to connect.
265
- try {
266
- fs.chmodSync(sockPath, SOCKET_FILE_MODE);
267
- }
268
- catch (err) {
269
- this.opts.logger?.warn("chmod 0600 on socket failed", err);
270
- }
271
- });
272
- // Symlink default path to this instance for CLI discovery
273
- try {
274
- fs.unlinkSync(MoltZapService.SOCKET_PATH);
275
- }
276
- catch (err) {
277
- // Stale/missing default symlink is expected most of the time; log
278
- // at debug so real permission errors still surface.
279
- this.opts.logger?.info("unlink default socket symlink", err);
280
- }
281
- try {
282
- fs.symlinkSync(sockPath, MoltZapService.SOCKET_PATH);
283
- }
284
- catch (err) {
285
- this.opts.logger?.warn("symlink default socket failed", err);
286
- }
254
+ const running = yield* startLocalSocketServer({
255
+ socketPath: this.socketPath,
256
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
257
+ handleRequest: (method, params) => this.handleSocketRequestEffect(method, params),
258
+ });
259
+ this.socketServerScope = running.socketScope;
260
+ this.activeSocketPath = running.socketPath;
261
+ }).pipe(Effect.withSpan("MoltZapService.startSocketServer"));
287
262
  }
288
- stopSocketServer() {
289
- this.socketServer?.close();
290
- this.socketServer = null;
263
+ resetSocketServerState() {
264
+ const socketScope = this.socketServerScope;
265
+ this.socketServerScope = null;
291
266
  const sockPath = this.activeSocketPath ?? this.socketPath;
292
267
  this.activeSocketPath = null;
293
- try {
294
- fs.unlinkSync(sockPath);
295
- }
296
- catch (err) {
297
- this.opts.logger?.info("unlink socket path", err);
298
- }
299
- // Remove default symlink only if it points to this instance.
300
- try {
301
- const target = fs.readlinkSync(MoltZapService.SOCKET_PATH);
302
- if (target === sockPath)
303
- fs.unlinkSync(MoltZapService.SOCKET_PATH);
304
- }
305
- catch (err) {
306
- this.opts.logger?.info("cleanup default symlink", err);
307
- }
268
+ return { socketScope, sockPath };
308
269
  }
309
- /**
310
- * Handle one JSON line from the unix socket as an Effect. Parses the
311
- * request, dispatches through `handleSocketRequestEffect`, and writes
312
- * either a `{result}` or `{error}` frame back onto the connection.
313
- * Never fails — all branches resolve into a `conn.write` side effect.
314
- */
315
- handleSocketLineEffect(line, conn) {
316
- return Effect.suspend(() => {
317
- let req;
318
- try {
319
- const parsed = JSON.parse(line);
320
- if (!isPlainRecord(parsed)) {
321
- return Effect.sync(() => conn.write(JSON.stringify({
322
- error: "request must be a JSON object",
323
- }) + "\n"));
324
- }
325
- req = parsed;
326
- }
327
- catch (err) {
328
- return Effect.sync(() => conn.write(JSON.stringify({
329
- error: err instanceof Error ? err.message : String(err),
330
- }) + "\n"));
331
- }
332
- if (typeof req.method !== "string" || !req.method) {
333
- return Effect.sync(() => conn.write(JSON.stringify({
334
- error: "method is required and must be a string",
335
- }) + "\n"));
336
- }
337
- const params = isPlainRecord(req.params) ? req.params : {};
338
- return this.handleSocketRequestEffect(req.method, params).pipe(Effect.match({
339
- onSuccess: (result) => {
340
- conn.write(JSON.stringify({ result }) + "\n");
341
- },
342
- onFailure: (err) => {
343
- conn.write(JSON.stringify({
344
- error: err instanceof Error ? err.message : String(err),
345
- }) + "\n");
346
- },
347
- }));
348
- });
270
+ stopSocketServer() {
271
+ const { socketScope, sockPath } = this.resetSocketServerState();
272
+ return stopLocalSocketServer({
273
+ socketScope,
274
+ socketPath: sockPath,
275
+ defaultSocketPath: MoltZapService.SOCKET_PATH,
276
+ }).pipe(Effect.withSpan("MoltZapService.stopSocketServer"));
349
277
  }
350
278
  handleSocketRequestEffect(method, params) {
351
279
  return Effect.suspend(() => {
@@ -362,7 +290,8 @@ export class MoltZapService {
362
290
  return this.handleHistoryRequest(params);
363
291
  default: {
364
292
  const definition = rpcMethods.find((d) => d.name === method);
365
- if (definition === undefined || !definition.validateParams(params)) {
293
+ if (definition === undefined ||
294
+ !definition.validateParams(params)) {
366
295
  return Effect.fail(new ServiceInputError({
367
296
  message: `unknown or invalid RPC method: ${method}`,
368
297
  }));
@@ -374,85 +303,80 @@ export class MoltZapService {
374
303
  }
375
304
  handleHistoryRequest(params) {
376
305
  return Effect.gen(this, function* () {
377
- if (typeof params.conversationId !== "string" || !params.conversationId) {
378
- return yield* Effect.fail(new ServiceInputError({
379
- message: "conversationId is required and must be a string",
380
- }));
381
- }
382
- if (params.limit !== undefined && typeof params.limit !== "number") {
383
- return yield* Effect.fail(new ServiceInputError({ message: "limit must be a number" }));
384
- }
385
- const convId = params.conversationId;
386
- const limit = params.limit ?? DEFAULT_HISTORY_LIMIT;
387
- const sessionKey = params.sessionKey;
306
+ const request = yield* decodeHistoryRequest(params);
388
307
  const result = yield* this.sendRpc(MessagesList, {
389
- conversationId: convId,
390
- limit,
308
+ conversationId: request.conversationId,
309
+ limit: request.limit,
391
310
  });
392
- // Resolve agent names (batch) + fetch conversation metadata (concurrent)
393
- const knownNames = yield* Ref.get(this.agentNamesRef);
394
- const unknownAgentIds = [
395
- ...new Set(result.messages.map((m) => m.senderId)),
396
- ].filter((id) => !HashMap.has(knownNames, id));
397
- const lookupEff = unknownAgentIds.length > 0
398
- ? this.sendRpc(AgentsLookup, { agentIds: unknownAgentIds }).pipe(Effect.tap((res) => {
399
- return Ref.update(this.agentNamesRef, (names) => {
400
- let next = names;
401
- for (const a of res.agents) {
402
- next = HashMap.set(next, a.id, a.name);
403
- }
404
- return next;
405
- });
406
- }), Effect.asVoid, Effect.catchAll(() => Effect.void))
407
- : Effect.void;
408
- const metaEff = this.sendRpc(ConversationsGet, {
409
- conversationId: convId,
410
- }).pipe(Effect.map((res) => res.conversation), Effect.catchAll(() => Effect.succeed(undefined)));
411
- const [, convMeta] = yield* Effect.all([lookupEff, metaEff], {
412
- concurrency: HISTORY_LOOKUP_CONCURRENCY,
413
- });
414
- // Determine what's "new" using lastRead (not lastNotified).
415
- // lastNotified is advanced by getContext() (system-reminder).
416
- // lastRead is advanced here when the agent explicitly reads history.
417
- const allAgentNames = yield* Ref.get(this.agentNamesRef);
311
+ const convMeta = yield* this.loadHistorySupportData(request.conversationId, result.messages);
312
+ const agentNames = yield* Ref.get(this.agentNamesRef);
418
313
  const lastReadMap = yield* Ref.get(this.lastReadRef);
419
- const lastReadIds = sessionKey
420
- ? Option.getOrElse(Option.flatMap(HashMap.get(lastReadMap, sessionKey), (perConv) => HashMap.get(perConv, convId)), () => new Set())
421
- : new Set();
422
- const messages = result.messages.map((m) => {
423
- const text = m.parts.map(renderPart).join(" ");
424
- const senderName = Option.getOrElse(HashMap.get(allAgentNames, m.senderId), () => m.senderId);
425
- return {
426
- id: m.id,
427
- senderId: m.senderId,
428
- senderName: m.senderId === this.ownAgentId ? "you" : senderName,
429
- isOwn: m.senderId === this.ownAgentId,
430
- text,
431
- createdAt: m.createdAt,
432
- isNew: sessionKey ? !lastReadIds.has(m.id) : false,
433
- };
434
- });
435
- // Advance lastRead to include all message IDs in the returned page.
436
- if (sessionKey && result.messages.length > 0) {
437
- yield* Ref.update(this.lastReadRef, (outer) => {
438
- const perSession = getOr(outer, sessionKey, () => HashMap.empty());
439
- const existing = getOr(perSession, convId, () => new Set());
440
- if (result.messages.every((m) => existing.has(m.id)))
441
- return outer;
442
- const nextSet = new Set(existing);
443
- for (const m of result.messages)
444
- nextSet.add(m.id);
445
- return HashMap.set(outer, sessionKey, HashMap.set(perSession, convId, nextSet));
446
- });
447
- }
314
+ const lastReadIds = lastReadIdsForSession(lastReadMap, request);
315
+ const messages = result.messages.map((message) => formatHistoryMessage(message, {
316
+ agentNames,
317
+ ownAgentId: this.ownAgentId,
318
+ lastReadIds,
319
+ hasSessionKey: request.sessionKey !== undefined,
320
+ }));
321
+ yield* this.advanceHistoryLastRead(request, result.messages);
448
322
  return {
449
323
  messages,
450
324
  hasMore: result.hasMore,
451
325
  conversationMeta: convMeta,
452
- newCount: messages.filter((m) => m.isNew).length,
326
+ newCount: messages.filter((message) => message.isNew).length,
453
327
  };
454
328
  });
455
329
  }
330
+ loadHistorySupportData(convId, messages) {
331
+ return Effect.gen(this, function* () {
332
+ const [, convMeta] = yield* Effect.all([
333
+ this.refreshHistoryAgentNames(messages),
334
+ this.fetchHistoryConversationMeta(convId),
335
+ ], {
336
+ concurrency: HISTORY_LOOKUP_CONCURRENCY,
337
+ });
338
+ return convMeta;
339
+ });
340
+ }
341
+ refreshHistoryAgentNames(messages) {
342
+ return Effect.gen(this, function* () {
343
+ const knownNames = yield* Ref.get(this.agentNamesRef);
344
+ const unknownAgentIds = [
345
+ ...new Set(messages.map((message) => message.senderId)),
346
+ ].filter((id) => !HashMap.has(knownNames, id));
347
+ if (unknownAgentIds.length === 0)
348
+ return;
349
+ yield* this.sendRpc(AgentsLookup, { agentIds: unknownAgentIds }).pipe(Effect.tap((result) => Ref.update(this.agentNamesRef, (names) => {
350
+ let next = names;
351
+ for (const agent of result.agents) {
352
+ next = HashMap.set(next, agent.id, agent.name);
353
+ }
354
+ return next;
355
+ })), Effect.asVoid, Effect.catchAll(() => Effect.void));
356
+ });
357
+ }
358
+ fetchHistoryConversationMeta(convId) {
359
+ return this.sendRpc(ConversationsGet, {
360
+ conversationId: convId,
361
+ }).pipe(Effect.map((result) => result.conversation), Effect.catchAll(() => Effect.succeed(undefined)));
362
+ }
363
+ advanceHistoryLastRead(request, messages) {
364
+ if (request.sessionKey === undefined || messages.length === 0) {
365
+ return Effect.void;
366
+ }
367
+ const { conversationId, sessionKey } = request;
368
+ return Ref.update(this.lastReadRef, (outer) => {
369
+ const perSession = getOr(outer, sessionKey, () => HashMap.empty());
370
+ const existing = getOr(perSession, conversationId, () => new Set());
371
+ if (messages.every((message) => existing.has(message.id)))
372
+ return outer;
373
+ const nextSet = new Set(existing);
374
+ for (const message of messages) {
375
+ nextSet.add(message.id);
376
+ }
377
+ return HashMap.set(outer, sessionKey, HashMap.set(perSession, conversationId, nextSet));
378
+ });
379
+ }
456
380
  // --- Conversations ---
457
381
  getConversation(convId) {
458
382
  return Option.getOrUndefined(HashMap.get(snapshot(this.conversationsRef), convId));
@@ -556,7 +480,8 @@ export class MoltZapService {
556
480
  * per viewing conversation and advanced after notification.
557
481
  */
558
482
  getContext(currentConvId, opts) {
559
- const { entries, commit } = this.peekContextEntries(currentConvId, opts);
483
+ const contextOptions = opts ?? {};
484
+ const { entries, commit } = this.peekContextEntries(currentConvId, contextOptions);
560
485
  if (entries.length === 0)
561
486
  return null;
562
487
  commit();
@@ -573,40 +498,9 @@ export class MoltZapService {
573
498
  peekContextEntries(currentConvId, opts) {
574
499
  const maxConvs = opts?.maxConversations ?? DEFAULT_MAX_CONTEXT_CONVERSATIONS;
575
500
  const maxMsgsPerConv = opts?.maxMessagesPerConv ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION;
576
- const { messagesMap, conversationsMap, agentNamesMap, viewMarkers } = this.readCrossConvState(currentConvId);
577
- // Collect all candidate conversations with their last-message timestamp,
578
- // then sort by recency before applying `maxConvs`. HashMap iteration
579
- // order is hash-based (not insertion/recency), so without the sort the
580
- // maxConvs truncation would pick an arbitrary subset and could omit the
581
- // freshest conversations.
582
- const candidates = [];
583
- for (const [convId, newMsgs] of this.iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId)) {
584
- const last = newMsgs[newMsgs.length - 1];
585
- candidates.push({
586
- convId,
587
- newMsgs,
588
- lastTs: new Date(last.createdAt).getTime(),
589
- });
590
- }
591
- candidates.sort((a, b) => b.lastTs - a.lastTs);
592
- const entries = [];
593
- const pendingAdvances = [];
594
- for (const { convId, newMsgs } of candidates.slice(0, maxConvs)) {
595
- const reportable = newMsgs.slice(-maxMsgsPerConv);
596
- const last = reportable[reportable.length - 1];
597
- const senderName = getOr(agentNamesMap, last.senderId, () => last.senderId);
598
- const minutesAgo = Math.max(0, Math.round((Date.now() - new Date(last.createdAt).getTime()) /
599
- MILLISECONDS_PER_MINUTE));
600
- entries.push({
601
- conversationId: convId,
602
- conversationName: Option.getOrUndefined(HashMap.get(conversationsMap, convId))?.name,
603
- senderName,
604
- text: last.parts.map(renderPart).join(" "),
605
- minutesAgo,
606
- count: reportable.length,
607
- });
608
- pendingAdvances.push([convId, last.id]);
609
- }
501
+ const state = this.readCrossConvState(currentConvId);
502
+ const candidates = this.collectContextCandidates(state, currentConvId);
503
+ const { entries, pendingAdvances } = buildContextEntries(candidates.slice(0, maxConvs), state, maxMsgsPerConv);
610
504
  return {
611
505
  entries,
612
506
  commit: () => this.advanceLastNotified(currentConvId, pendingAdvances),
@@ -650,18 +544,19 @@ export class MoltZapService {
650
544
  viewMarkers: getOr(lastNotifiedMap, currentConvId, () => HashMap.empty()),
651
545
  };
652
546
  }
547
+ collectContextCandidates(state, currentConvId) {
548
+ const candidates = [];
549
+ for (const [convId, newMsgs] of this.iterNewMessagesByConv(state.messagesMap, state.viewMarkers, currentConvId)) {
550
+ candidates.push(makeContextCandidate(convId, newMsgs));
551
+ }
552
+ candidates.sort((a, b) => b.lastTs - a.lastTs);
553
+ return candidates;
554
+ }
653
555
  *iterNewMessagesByConv(messagesMap, viewMarkers, currentConvId) {
654
556
  for (const [convId, msgs] of messagesMap) {
655
- if (convId === currentConvId || msgs.length === 0)
656
- continue;
657
- const lastSeenId = Option.getOrUndefined(HashMap.get(viewMarkers, convId));
658
- const lastSeenIdx = lastSeenId
659
- ? msgs.findIndex((m) => m.id === lastSeenId)
660
- : -1;
661
- const newMsgs = msgs.slice(lastSeenIdx + 1);
662
- if (newMsgs.length === 0)
663
- continue;
664
- yield [convId, newMsgs];
557
+ const newMsgs = newMessagesForConversation(convId, msgs, viewMarkers, currentConvId);
558
+ if (newMsgs.length > 0)
559
+ yield [convId, newMsgs];
665
560
  }
666
561
  }
667
562
  advanceLastNotified(currentConvId, pendingAdvances) {
@@ -716,70 +611,19 @@ export class MoltZapService {
716
611
  Effect.catchAll(() => Effect.void));
717
612
  }
718
613
  handleNotification(notification) {
719
- const params = isPlainRecord(notification.params)
720
- ? notification.params
721
- : {};
722
- const message = isPlainRecord(params["message"])
723
- ? params["message"]
724
- : undefined;
725
- const conversation = isPlainRecord(params["conversation"])
726
- ? params["conversation"]
727
- : undefined;
728
- const notificationConversationId = typeof params["conversationId"] === "string"
729
- ? params["conversationId"]
730
- : undefined;
731
- appendClientEventTrace({
732
- ts: new Date().toISOString(),
733
- agentId: this._ownAgentId ?? "unknown",
734
- notification: notification.method,
735
- messageId: message?.["id"],
736
- messageConversationId: message?.["conversationId"],
737
- messageSenderId: message?.["senderId"],
738
- conversationId: conversation?.["id"] ?? notificationConversationId,
739
- conversationName: conversation?.["name"],
740
- });
741
- fanout(this.handlers.rawNotification, notification, this.opts.logger);
742
- // Direct descriptor-keyed dispatch. Each branch narrows on the
743
- // descriptor identity carried by the decoded notification — same
744
- // payload-validation guarantee as before, without the extra
745
- // group-decoder indirection (params were validated upstream by
746
- // `decodeServerInbound` / `decodeFrames`).
747
- switch (notification.definition) {
748
- case MessageReceivedNotificationDefinition:
749
- this.handleMessageReceivedNotification(notification.params);
750
- return;
751
- case ConversationCreatedNotificationDefinition:
752
- this.handleConversationCreatedNotification(notification.params);
753
- return;
754
- case ConversationUpdatedNotificationDefinition:
755
- this.handleConversationUpdatedNotification(notification.params);
756
- return;
757
- case ConversationArchivedNotificationDefinition: {
758
- const params = notification.params;
759
- this.markConversationArchived(params.conversationId);
760
- fanout(this.handlers.conversationArchived, params, this.opts.logger);
761
- return;
762
- }
763
- case ConversationUnarchivedNotificationDefinition: {
764
- const params = notification.params;
765
- this.archivedConversationIds.delete(params.conversationId);
766
- fanout(this.handlers.conversationUnarchived, params, this.opts.logger);
767
- return;
768
- }
769
- case DispatchRelease:
770
- fanout(this.handlers.dispatchRelease, notification.params, this.opts.logger);
771
- return;
772
- case DispatchesConsumed:
773
- fanout(this.handlers.dispatchesConsumed, notification.params, this.opts.logger);
774
- return;
775
- case DispatchesExpired:
776
- fanout(this.handlers.dispatchesExpired, notification.params, this.opts.logger);
777
- return;
778
- default:
779
- // Other notification descriptors flow through `rawNotification`
780
- // fanout above; the service has no typed bridge for them.
781
- return;
614
+ this.recordNotificationTrace(notification);
615
+ fanout(this.handlers.rawNotification, notification);
616
+ this.dispatchTypedNotification(notification);
617
+ }
618
+ recordNotificationTrace(notification) {
619
+ Effect.runFork(appendClientEventTrace(notificationTraceRecord(notification, this._ownAgentId)));
620
+ }
621
+ dispatchTypedNotification(notification) {
622
+ const dispatch = this.notificationDispatchers.get(notification.definition);
623
+ if (dispatch === undefined) {
624
+ return;
782
625
  }
626
+ dispatch(notification);
783
627
  }
784
628
  /**
785
629
  * Record `messageId` in the per-conversation dedup window. Returns
@@ -820,7 +664,7 @@ export class MoltZapService {
820
664
  // via resolveAgentName(), which populates agentNamesRef on first miss and
821
665
  // hits the cache on every subsequent message.
822
666
  if (msg.senderId !== this._ownAgentId) {
823
- fanout(this.handlers.message, msg, this.opts.logger);
667
+ fanout(this.handlers.message, msg);
824
668
  }
825
669
  }
826
670
  handleConversationCreatedNotification(notification) {
@@ -830,6 +674,14 @@ export class MoltZapService {
830
674
  handleConversationUpdatedNotification(notification) {
831
675
  this.upsertConversationNotification(notification);
832
676
  }
677
+ handleConversationArchivedNotification(notification) {
678
+ this.markConversationArchived(notification.conversationId);
679
+ fanout(this.handlers.conversationArchived, notification);
680
+ }
681
+ handleConversationUnarchivedNotification(notification) {
682
+ this.archivedConversationIds.delete(notification.conversationId);
683
+ fanout(this.handlers.conversationUnarchived, notification);
684
+ }
833
685
  upsertConversationNotification(notification) {
834
686
  const { conversation } = notification;
835
687
  this.archivedConversationIds.delete(conversation.id);