@myagentroam/node 0.9.126 → 0.9.128

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.
@@ -0,0 +1,69 @@
1
+ import { createEnvelope, mailNotificationRequestSchema, mailNotificationResponseSchema } from '@myagentroam/protocol';
2
+ export class MailNotificationControl {
3
+ sendEnvelope;
4
+ pending = new Map();
5
+ generation = 0;
6
+ connectedToServer = false;
7
+ closed = false;
8
+ constructor(sendEnvelope) {
9
+ this.sendEnvelope = sendEnvelope;
10
+ }
11
+ connected() {
12
+ this.disconnected();
13
+ this.connectedToServer = true;
14
+ }
15
+ disconnected() {
16
+ this.connectedToServer = false;
17
+ this.generation++;
18
+ for (const pending of [...this.pending.values()])
19
+ pending.reject(new Error('MAIL_DELIVERY_UNCERTAIN'));
20
+ }
21
+ close() {
22
+ this.closed = true;
23
+ this.disconnected();
24
+ }
25
+ handle(envelope) {
26
+ if (envelope.type !== 'mail.notification.response')
27
+ return false;
28
+ const pending = envelope.replyTo === undefined ? undefined : this.pending.get(envelope.replyTo);
29
+ if (pending === undefined || pending.generation !== this.generation)
30
+ return true;
31
+ const parsed = mailNotificationResponseSchema.safeParse(envelope.payload);
32
+ if (!parsed.success)
33
+ pending.reject(new Error('MAIL_RESPONSE_INVALID'));
34
+ else if ('error' in parsed.data)
35
+ pending.reject(new Error(parsed.data.error.code));
36
+ else
37
+ pending.resolve();
38
+ return true;
39
+ }
40
+ request(input) {
41
+ if (!this.connectedToServer || this.closed)
42
+ return Promise.reject(new Error('MAIL_SERVER_UNAVAILABLE'));
43
+ const envelope = createEnvelope('mail.notification.send', mailNotificationRequestSchema.parse(input));
44
+ return new Promise((resolve, reject) => {
45
+ const settle = (action) => {
46
+ const pending = this.pending.get(envelope.id);
47
+ if (pending === undefined)
48
+ return;
49
+ clearTimeout(pending.timer);
50
+ this.pending.delete(envelope.id);
51
+ action();
52
+ };
53
+ const timer = setTimeout(() => settle(() => reject(new Error('MAIL_DELIVERY_UNCERTAIN'))), 30_000);
54
+ timer.unref();
55
+ this.pending.set(envelope.id, {
56
+ generation: this.generation,
57
+ resolve: () => settle(resolve),
58
+ reject: (error) => settle(() => reject(error)),
59
+ timer
60
+ });
61
+ try {
62
+ this.sendEnvelope(envelope);
63
+ }
64
+ catch {
65
+ settle(() => reject(new Error('MAIL_SERVER_UNAVAILABLE')));
66
+ }
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,153 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { unlink } from 'node:fs/promises';
4
+ import { createServer } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { mailNotificationRequestSchema } from '@myagentroam/protocol';
9
+ export class MailNotificationMcpServer {
10
+ resolve;
11
+ send;
12
+ capabilities = new Map();
13
+ sockets = new Set();
14
+ endpoint = process.platform === 'win32'
15
+ ? `\\\\.\\pipe\\mar-mail-${process.pid}-${randomUUID()}`
16
+ : join(tmpdir(), `mar-mail-${process.pid}-${randomUUID()}.sock`);
17
+ listening;
18
+ constructor(resolve, send) {
19
+ this.resolve = resolve;
20
+ this.send = send;
21
+ }
22
+ async installation(capability) {
23
+ await this.listen();
24
+ const token = randomBytes(32).toString('base64url');
25
+ const secretName = `MAR_MAIL_MCP_${randomBytes(16).toString('hex').toUpperCase()}`;
26
+ this.capabilities.set(token, capability);
27
+ return {
28
+ installation: {
29
+ __marMail: true,
30
+ serverName: 'mar_notification',
31
+ enabled: true,
32
+ command: process.execPath,
33
+ args: mailMcpStdioArgs(),
34
+ endpoint: this.endpoint,
35
+ secretName
36
+ },
37
+ secrets: { [secretName]: token }
38
+ };
39
+ }
40
+ revoke(executionId) {
41
+ for (const [token, capability] of this.capabilities)
42
+ if (capability.executionId === executionId)
43
+ this.capabilities.delete(token);
44
+ }
45
+ revokeAll() {
46
+ this.capabilities.clear();
47
+ }
48
+ async close() {
49
+ if (this.listening === undefined)
50
+ return;
51
+ const server = await this.listening;
52
+ this.revokeAll();
53
+ const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
54
+ for (const socket of this.sockets)
55
+ socket.destroy();
56
+ await closed;
57
+ if (process.platform !== 'win32')
58
+ await unlink(this.endpoint).catch(() => undefined);
59
+ }
60
+ listen() {
61
+ this.listening ??= new Promise((resolve, reject) => {
62
+ const server = createServer((socket) => this.handle(socket));
63
+ server.once('error', reject);
64
+ server.listen(this.endpoint, () => resolve(server));
65
+ });
66
+ return this.listening;
67
+ }
68
+ handle(socket) {
69
+ this.sockets.add(socket);
70
+ socket.once('close', () => this.sockets.delete(socket));
71
+ let body = '';
72
+ let received = false;
73
+ socket.setEncoding('utf8');
74
+ socket.on('data', (chunk) => {
75
+ if (received)
76
+ return;
77
+ body += chunk;
78
+ if (body.length > 16_000) {
79
+ socket.destroy();
80
+ return;
81
+ }
82
+ if (body.includes('\n')) {
83
+ received = true;
84
+ void this.respond(socket, body.slice(0, body.indexOf('\n')));
85
+ }
86
+ });
87
+ }
88
+ async respond(socket, body) {
89
+ try {
90
+ const request = JSON.parse(body);
91
+ const capability = typeof request['token'] === 'string' ? this.capabilities.get(request['token']) : undefined;
92
+ if (capability === undefined)
93
+ throw new Error('MAIL_CAPABILITY_INVALID');
94
+ const current = this.resolve(capability);
95
+ if (current === undefined)
96
+ throw new Error('MAIL_CAPABILITY_STALE');
97
+ if (request['tool'] !== 'mar_send_email_notification')
98
+ throw new Error('MAIL_TOOL_INVALID');
99
+ const args = request['arguments'];
100
+ if (args === null || typeof args !== 'object' || Array.isArray(args))
101
+ throw new Error('MAIL_INPUT_INVALID');
102
+ const { templateId, title, recipientEmail, message, callId } = args;
103
+ if ((templateId !== undefined && typeof templateId !== 'string') ||
104
+ (title !== undefined &&
105
+ (typeof title !== 'string' || title.length > 160 || /[\r\n]/u.test(title))) ||
106
+ typeof recipientEmail !== 'string' ||
107
+ typeof message !== 'string' ||
108
+ typeof callId !== 'string')
109
+ throw new Error('MAIL_INPUT_INVALID');
110
+ const parsed = mailNotificationRequestSchema.safeParse({
111
+ ...current,
112
+ templateId: templateId ?? '',
113
+ ...(title === undefined ? {} : { title }),
114
+ recipientEmail,
115
+ message,
116
+ callId
117
+ });
118
+ if (!parsed.success)
119
+ throw new Error('MAIL_INPUT_INVALID');
120
+ await this.send(parsed.data);
121
+ socket.end(`${JSON.stringify({ result: { accepted: true } })}\n`);
122
+ }
123
+ catch (error) {
124
+ const code = error instanceof Error && /^MAIL_[A-Z0-9_]+$/u.test(error.message)
125
+ ? error.message
126
+ : 'MAIL_DELIVERY_UNCERTAIN';
127
+ socket.end(`${JSON.stringify({ error: code })}\n`);
128
+ }
129
+ }
130
+ }
131
+ export function resolveMailNotificationCapability(runtime, capability) {
132
+ const run = runtime.getRun(capability.executionId);
133
+ const session = runtime.getAgentSession(capability.sessionId);
134
+ if (run === undefined ||
135
+ session === undefined ||
136
+ ['SUCCEEDED', 'FAILED', 'INTERRUPTED'].includes(run.status) ||
137
+ runtime.currentExecutionId(run.id) !== capability.executionId ||
138
+ run.sessionId !== session.id ||
139
+ run.workspaceId !== capability.workspaceId ||
140
+ run.initiatedByUserId !== capability.userId ||
141
+ (run.source === 'SCHEDULED' ? 'SCHEDULED' : 'MANUAL') !== capability.source)
142
+ return undefined;
143
+ return { ...capability, sessionId: session.id };
144
+ }
145
+ function mailMcpStdioArgs() {
146
+ const javascript = fileURLToPath(new URL('./mail-notification-mcp-stdio.js', import.meta.url));
147
+ if (existsSync(javascript))
148
+ return [javascript];
149
+ const typescript = fileURLToPath(new URL('./mail-notification-mcp-stdio.ts', import.meta.url));
150
+ if (!existsSync(typescript))
151
+ throw new Error('MAIL_MCP_STDIO_ENTRY_UNAVAILABLE');
152
+ return ['--import', import.meta.resolve('tsx'), typescript];
153
+ }
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { connect } from 'node:net';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ const endpoint = process.env['MAR_MAIL_MCP_ENDPOINT'];
7
+ const token = process.env['MAR_MAIL_MCP_TOKEN'];
8
+ if (endpoint === undefined || token === undefined)
9
+ throw new Error('MAIL_CAPABILITY_INVALID');
10
+ const server = new McpServer({ name: 'mar_notification', version: '1.0.0' });
11
+ server.registerTool('mar_send_email_notification', {
12
+ description: 'Send a notification email to the specified recipient from this MAR-managed Workspace conversation. The Agent works with files and tools on the authorized MAR Node; the MAR Server sends email using its administrator-configured SMTP account. This is not the browser or Node local email account, nor a repository cron/CI notification. This tool cannot access SMTP credentials. Omit the template to use the built-in notification template in the sender’s preferred language; optionally select notification-en-US or notification-zh-CN. Each call may send a real email; do not retry after an uncertain result.',
13
+ inputSchema: {
14
+ templateId: z
15
+ .string()
16
+ .max(64)
17
+ .optional()
18
+ .describe('Optional built-in NOTIFICATION template ID; omitted or empty uses the sender’s preferred language.'),
19
+ title: z
20
+ .string()
21
+ .max(160)
22
+ .regex(/^[^\r\n]*$/u)
23
+ .optional()
24
+ .describe('Optional notification title rendered into the email subject.'),
25
+ recipientEmail: z.string().min(3).max(320).describe('Single recipient email address.'),
26
+ message: z.string().min(1).max(4000).describe('Notification text for {{message}}.')
27
+ }
28
+ }, async (args) => {
29
+ const value = await request({ ...args, callId: randomUUID() });
30
+ return { content: [{ type: 'text', text: JSON.stringify(value) }] };
31
+ });
32
+ await server.connect(new StdioServerTransport());
33
+ function request(args) {
34
+ return new Promise((resolve, reject) => {
35
+ const socket = connect(endpoint);
36
+ let body = '';
37
+ socket.setEncoding('utf8');
38
+ socket.once('connect', () => socket.write(`${JSON.stringify({ token, tool: 'mar_send_email_notification', arguments: args })}\n`));
39
+ socket.on('data', (chunk) => {
40
+ body += chunk;
41
+ if (body.length > 16_000)
42
+ socket.destroy(new Error('MAIL_RESPONSE_INVALID'));
43
+ });
44
+ socket.once('error', reject);
45
+ socket.once('end', () => {
46
+ try {
47
+ if (body.length === 0)
48
+ throw new Error('MAIL_DELIVERY_UNCERTAIN');
49
+ const payload = JSON.parse(body);
50
+ if (payload.error !== undefined)
51
+ reject(new Error(payload.error));
52
+ else
53
+ resolve(payload.result);
54
+ }
55
+ catch (error) {
56
+ reject(error instanceof Error && error.message === 'MAIL_DELIVERY_UNCERTAIN'
57
+ ? error
58
+ : new Error('MAIL_RESPONSE_INVALID'));
59
+ }
60
+ });
61
+ });
62
+ }
@@ -39,7 +39,13 @@ export class McpNodeOperationService {
39
39
  status: 'CURRENT',
40
40
  installedAt: installation.installedAt
41
41
  }));
42
- return mcpTargetInspectionSchema.parse({ targetKind, installations });
42
+ return mcpTargetInspectionSchema.parse({
43
+ targetKind,
44
+ installations,
45
+ ...(targetKind === 'WORKSPACE'
46
+ ? { executionBuiltIns: ['mar_schedule', 'mar_notification'] }
47
+ : {})
48
+ });
43
49
  }
44
50
  async install(targetKind, targetId, raw) {
45
51
  if (!isRecord(raw))
@@ -37,6 +37,8 @@ export class NodeControlMessageService {
37
37
  return;
38
38
  if (this.options.handleScheduledRunAuthorizeResponse?.(envelope) === true)
39
39
  return;
40
+ if (this.options.handleMailNotificationResponse?.(envelope) === true)
41
+ return;
40
42
  if (envelope.type.startsWith('direct.') &&
41
43
  this.options.handleDirectTransfer?.(envelope) === true)
42
44
  return;
@@ -40,6 +40,7 @@ export class ScheduledSessionStore {
40
40
  workspaceId: input.workspaceId,
41
41
  runner: input.runner,
42
42
  ownerUserId: input.ownerUserId,
43
+ notifyOnTerminal: input.notifyOnTerminal ?? false,
43
44
  cron: input.cron,
44
45
  timezone: input.timezone,
45
46
  objective: input.objective.trim(),
@@ -52,9 +53,9 @@ export class ScheduledSessionStore {
52
53
  };
53
54
  this.database
54
55
  .prepare(`INSERT INTO scheduled_session_tasks
55
- (id,session_id,workspace_id,runner,owner_user_id,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at)
56
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
57
- .run(task.id, task.sessionId, task.workspaceId, task.runner, task.ownerUserId, task.objective, task.cron, task.timezone, task.enabled ? 1 : 0, 1, task.nextRunAt, now, now);
56
+ (id,session_id,workspace_id,runner,owner_user_id,notify_on_terminal,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at)
57
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
58
+ .run(task.id, task.sessionId, task.workspaceId, task.runner, task.ownerUserId, task.notifyOnTerminal ? 1 : 0, task.objective, task.cron, task.timezone, task.enabled ? 1 : 0, 1, task.nextRunAt, now, now);
58
59
  return task;
59
60
  }
60
61
  update(sessionId, expectedRevision, patch) {
@@ -63,11 +64,15 @@ export class ScheduledSessionStore {
63
64
  throw new Error('SCHEDULE_NOT_FOUND');
64
65
  if (current.revision !== expectedRevision)
65
66
  throw new Error('SCHEDULE_REVISION_CONFLICT');
67
+ if (patch.ownerUserId !== undefined &&
68
+ (!Number.isSafeInteger(patch.ownerUserId) || patch.ownerUserId < 1))
69
+ throw new Error('SCHEDULE_OWNER_INVALID');
66
70
  const objective = patch.objective ?? current.objective;
67
71
  const cron = patch.cron ?? current.cron;
68
72
  const timezone = patch.timezone ?? current.timezone;
69
73
  validate(objective, cron, timezone);
70
74
  const enabled = patch.enabled ?? current.enabled;
75
+ const ownerUserId = patch.ownerUserId ?? current.ownerUserId;
71
76
  const nextRunAt = enabled
72
77
  ? patch.enabled === true || patch.cron !== undefined || patch.timezone !== undefined
73
78
  ? nextScheduledAt(cron, timezone, this.now())
@@ -75,9 +80,9 @@ export class ScheduledSessionStore {
75
80
  : null;
76
81
  const now = this.now();
77
82
  const result = this.database
78
- .prepare(`UPDATE scheduled_session_tasks SET objective=?,cron=?,timezone=?,enabled=?,revision=?,next_run_at=?,updated_at=?
83
+ .prepare(`UPDATE scheduled_session_tasks SET objective=?,cron=?,timezone=?,enabled=?,owner_user_id=?,notify_on_terminal=?,revision=?,next_run_at=?,updated_at=?
79
84
  WHERE session_id=? AND revision=?`)
80
- .run(objective.trim(), cron, timezone, enabled ? 1 : 0, expectedRevision + 1, nextRunAt, now, sessionId, expectedRevision);
85
+ .run(objective.trim(), cron, timezone, enabled ? 1 : 0, ownerUserId, (patch.notifyOnTerminal ?? current.notifyOnTerminal) ? 1 : 0, expectedRevision + 1, nextRunAt, now, sessionId, expectedRevision);
81
86
  if (result.changes !== 1)
82
87
  throw new Error('SCHEDULE_REVISION_CONFLICT');
83
88
  this.database
@@ -193,7 +198,8 @@ export class ScheduledSessionStore {
193
198
  ? {}
194
199
  : { objective: proposal['objective'] }),
195
200
  ...(proposal['cron'] === null ? {} : { cron: proposal['cron'] }),
196
- ...(proposal['timezone'] === null ? {} : { timezone: proposal['timezone'] })
201
+ ...(proposal['timezone'] === null ? {} : { timezone: proposal['timezone'] }),
202
+ ownerUserId
197
203
  })
198
204
  : task;
199
205
  this.database
@@ -222,6 +228,7 @@ function taskFromRow(row) {
222
228
  workspaceId: row['workspace_id'],
223
229
  runner: row['runner'],
224
230
  ownerUserId: Number(row['owner_user_id']),
231
+ notifyOnTerminal: row['notify_on_terminal'] === 1,
225
232
  objective: row['objective'],
226
233
  cron: row['cron'],
227
234
  timezone: row['timezone'],
@@ -144,7 +144,10 @@ export class ScheduledTaskMcpServer {
144
144
  ...(cron === undefined ? {} : { cron: cron })
145
145
  };
146
146
  if (capability.actorUserId === task.ownerUserId) {
147
- const updated = this.store().update(task.sessionId, task.revision, patch);
147
+ const updated = this.store().update(task.sessionId, task.revision, {
148
+ ...patch,
149
+ ownerUserId: capability.actorUserId
150
+ });
148
151
  this.#capabilities.set(request.token, {
149
152
  ...capability,
150
153
  revision: updated.revision
@@ -48,12 +48,14 @@ export class SessionScheduleService {
48
48
  const current = store.get(sessionId);
49
49
  const objective = input.objective;
50
50
  const cron = input.cron;
51
+ const notifyOnTerminal = input.notifyOnTerminal;
51
52
  const timezone = current === undefined ? nodeTimezone() : undefined;
52
53
  if ((objective !== undefined &&
53
54
  (typeof objective !== 'string' ||
54
55
  objective.trim().length < 1 ||
55
56
  objective.trim().length > 20_000)) ||
56
57
  (cron !== undefined && typeof cron !== 'string') ||
58
+ (notifyOnTerminal !== undefined && typeof notifyOnTerminal !== 'boolean') ||
57
59
  input.timezone !== undefined ||
58
60
  (input.enabled !== undefined && typeof input.enabled !== 'boolean'))
59
61
  throw new Error('SCHEDULE_INVALID');
@@ -82,6 +84,7 @@ export class SessionScheduleService {
82
84
  workspaceId: session.workspaceId,
83
85
  runner: session.runner,
84
86
  ownerUserId: actor,
87
+ ...(notifyOnTerminal === undefined ? {} : { notifyOnTerminal }),
85
88
  objective: objective,
86
89
  cron: cron,
87
90
  timezone: timezone,
@@ -91,7 +94,9 @@ export class SessionScheduleService {
91
94
  ...(objective === undefined ? {} : { objective: objective }),
92
95
  ...(cron === undefined ? {} : { cron: cron }),
93
96
  ...(timezone === undefined ? {} : { timezone: timezone }),
94
- ...(input.enabled === undefined ? {} : { enabled: input.enabled })
97
+ ...(input.enabled === undefined ? {} : { enabled: input.enabled }),
98
+ ownerUserId: actor,
99
+ ...(notifyOnTerminal === undefined ? {} : { notifyOnTerminal })
95
100
  });
96
101
  const updated = this.options.runtime.getAgentSession(session.id) ?? session;
97
102
  this.options.emitSession(updated);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.126",
3
+ "version": "0.9.128",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "npm": "11.12.1",
31
31
  "ws": "^8.21.3",
32
32
  "zod": "4.4.3",
33
- "@myagentroam/agent": "0.9.126",
34
- "@myagentroam/protocol": "0.9.126",
35
- "@myagentroam/code-navigation": "0.9.126"
33
+ "@myagentroam/agent": "0.9.128",
34
+ "@myagentroam/protocol": "0.9.128",
35
+ "@myagentroam/code-navigation": "0.9.128"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/ws": "^8.18.1"