arc402-cli 0.7.1 → 0.7.3

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.
@@ -19,7 +19,9 @@ import {
19
19
  DAEMON_SOCK,
20
20
  DAEMON_TOML,
21
21
  TEMPLATE_DAEMON_TOML,
22
+ loadDaemonConfig,
22
23
  } from "../daemon/config";
24
+ import { buildNotifier } from "../daemon/notify";
23
25
  import {
24
26
  buildOpenShellSecretExports,
25
27
  buildOpenShellSshConfig,
@@ -943,6 +945,77 @@ export function registerDaemonCommands(program: Command): void {
943
945
  console.log(" 5. Start the OpenShell-owned ARC-402 runtime: arc402 daemon start");
944
946
  });
945
947
 
948
+ // ── daemon notifications ──────────────────────────────────────────────────────
949
+ const notifications = daemon
950
+ .command("notifications")
951
+ .description("Show or test configured notification channels");
952
+
953
+ notifications
954
+ .command("show")
955
+ .description("Show all configured notification channels")
956
+ .action(() => {
957
+ if (!fs.existsSync(DAEMON_TOML)) {
958
+ console.error("daemon.toml not found. Run `arc402 daemon init` first.");
959
+ process.exit(1);
960
+ }
961
+ const cfg = loadDaemonConfig();
962
+ const notif = cfg.notifications;
963
+ const channels: string[] = [];
964
+
965
+ if (notif.telegram_bot_token && notif.telegram_chat_id) {
966
+ channels.push(`telegram chat_id=${notif.telegram_chat_id}`);
967
+ }
968
+ if (notif.discord?.webhook_url) {
969
+ const u = new URL(notif.discord.webhook_url);
970
+ channels.push(`discord ${u.hostname}${u.pathname.slice(0, 30)}...`);
971
+ }
972
+ if (notif.webhook?.url) {
973
+ channels.push(`webhook ${notif.webhook.url}`);
974
+ }
975
+ if (notif.email?.smtp_host && notif.email?.to) {
976
+ channels.push(`email ${notif.email.smtp_host}:${notif.email.smtp_port} → ${notif.email.to}`);
977
+ }
978
+
979
+ if (channels.length === 0) {
980
+ console.log("No notification channels configured.");
981
+ console.log("Edit ~/.arc402/daemon.toml to add Telegram, Discord, webhook, or email.");
982
+ } else {
983
+ console.log(`Configured channels (${channels.length}):`);
984
+ for (const ch of channels) console.log(` ${ch}`);
985
+ }
986
+ });
987
+
988
+ notifications
989
+ .command("test")
990
+ .description("Send a test message to all configured channels")
991
+ .action(async () => {
992
+ if (!fs.existsSync(DAEMON_TOML)) {
993
+ console.error("daemon.toml not found. Run `arc402 daemon init` first.");
994
+ process.exit(1);
995
+ }
996
+ const cfg = loadDaemonConfig();
997
+ const notifier = buildNotifier(cfg);
998
+ if (!notifier.isEnabled()) {
999
+ console.log("No notification channels configured. Nothing to test.");
1000
+ process.exit(0);
1001
+ }
1002
+ console.log("Sending test notification to all channels...");
1003
+ try {
1004
+ await notifier.send("daemon_started", "ARC-402 Test Notification",
1005
+ "This is a test message from arc402 daemon notifications test."
1006
+ );
1007
+ console.log("Test notification sent successfully.");
1008
+ } catch (err) {
1009
+ console.error(`Test notification failed: ${err instanceof Error ? err.message : String(err)}`);
1010
+ process.exit(1);
1011
+ }
1012
+ });
1013
+
1014
+ // Default action: show (arc402 daemon notifications → arc402 daemon notifications show)
1015
+ notifications.action(() => {
1016
+ notifications.help();
1017
+ });
1018
+
946
1019
  // ── daemon channel-watch ─────────────────────────────────────────────────────
947
1020
  daemon
948
1021
  .command("channel-watch")
@@ -68,6 +68,9 @@ export interface DaemonConfig {
68
68
  notify_on_channel_challenge: boolean;
69
69
  notify_on_low_balance: boolean;
70
70
  low_balance_threshold_eth: string;
71
+ discord: { webhook_url: string };
72
+ webhook: { url: string; headers: Record<string, string> };
73
+ email: { smtp_host: string; smtp_port: number; smtp_user: string; smtp_pass: string; to: string };
71
74
  };
72
75
  work: {
73
76
  handler: "exec" | "http" | "noop";
@@ -114,6 +117,9 @@ function withDefaults(raw: Record<string, unknown>): DaemonConfig {
114
117
  const wt = (raw.watchtower as Record<string, unknown>) ?? {};
115
118
  const p = (raw.policy as Record<string, unknown>) ?? {};
116
119
  const notif = (raw.notifications as Record<string, unknown>) ?? {};
120
+ const notifDiscord = (notif.discord as Record<string, unknown>) ?? {};
121
+ const notifWebhook = (notif.webhook as Record<string, unknown>) ?? {};
122
+ const notifEmail = (notif.email as Record<string, unknown>) ?? {};
117
123
  const work = (raw.work as Record<string, unknown>) ?? {};
118
124
 
119
125
  return {
@@ -169,6 +175,18 @@ function withDefaults(raw: Record<string, unknown>): DaemonConfig {
169
175
  notify_on_channel_challenge: bool(notif.notify_on_channel_challenge, true),
170
176
  notify_on_low_balance: bool(notif.notify_on_low_balance, true),
171
177
  low_balance_threshold_eth: str(notif.low_balance_threshold_eth, "0.005"),
178
+ discord: { webhook_url: str(notifDiscord.webhook_url) },
179
+ webhook: {
180
+ url: str(notifWebhook.url),
181
+ headers: (notifWebhook.headers as Record<string, string>) ?? {},
182
+ },
183
+ email: {
184
+ smtp_host: str(notifEmail.smtp_host),
185
+ smtp_port: num(notifEmail.smtp_port, 587),
186
+ smtp_user: str(notifEmail.smtp_user),
187
+ smtp_pass: str(notifEmail.smtp_pass, "env:SMTP_PASS"),
188
+ to: str(notifEmail.to),
189
+ },
172
190
  },
173
191
  work: {
174
192
  handler: (str(work.handler, "noop")) as "exec" | "http" | "noop",
@@ -213,6 +231,9 @@ export function loadDaemonConfig(configPath = DAEMON_TOML): DaemonConfig {
213
231
  // Resolve optional env: values silently (missing = disabled feature)
214
232
  config.notifications.telegram_bot_token = tryResolveEnvValue(config.notifications.telegram_bot_token);
215
233
  config.notifications.telegram_chat_id = tryResolveEnvValue(config.notifications.telegram_chat_id);
234
+ config.notifications.discord.webhook_url = tryResolveEnvValue(config.notifications.discord.webhook_url);
235
+ config.notifications.webhook.url = tryResolveEnvValue(config.notifications.webhook.url);
236
+ config.notifications.email.smtp_pass = tryResolveEnvValue(config.notifications.email.smtp_pass);
216
237
  config.work.http_auth_token = tryResolveEnvValue(config.work.http_auth_token);
217
238
 
218
239
  return config;
@@ -300,6 +321,20 @@ notify_on_channel_challenge = true # Notify when watchtower submits a channel c
300
321
  notify_on_low_balance = false # Disabled by default — enable if you want balance alerts
301
322
  low_balance_threshold_eth = "0.005" # Balance alert threshold
302
323
 
324
+ [notifications.discord]
325
+ webhook_url = "" # Discord channel webhook URL (leave empty to disable)
326
+
327
+ [notifications.webhook]
328
+ url = "" # POST JSON {title, body, timestamp} to this URL (leave empty to disable)
329
+ # headers = { Authorization = "Bearer ..." } # Optional headers
330
+
331
+ [notifications.email]
332
+ smtp_host = "" # SMTP server hostname (leave empty to disable)
333
+ smtp_port = 587
334
+ smtp_user = "" # SMTP login / from address
335
+ smtp_pass = "env:SMTP_PASS" # Load from env, not hardcoded
336
+ to = "" # Recipient address
337
+
303
338
  [work]
304
339
  handler = "noop" # exec | http | noop
305
340
  exec_command = "" # called with agreementId and spec as args (exec mode)
@@ -27,7 +27,7 @@ import {
27
27
  type DaemonConfig,
28
28
  } from "./config";
29
29
  import { verifyWallet, getWalletBalance } from "./wallet-monitor";
30
- import { Notifier } from "./notify";
30
+ import { buildNotifier } from "./notify";
31
31
  import { HireListener } from "./hire-listener";
32
32
  import { UserOpsManager, buildAcceptCalldata } from "./userops";
33
33
  import { generateReceipt, extractLearnings, createJobDirectory, cleanJobDirectory } from "./job-lifecycle";
@@ -519,19 +519,7 @@ export async function runDaemon(foreground = false): Promise<void> {
519
519
  }
520
520
 
521
521
  // ── Setup notifier ───────────────────────────────────────────────────────
522
- const notifier = new Notifier(
523
- config.notifications.telegram_bot_token,
524
- config.notifications.telegram_chat_id,
525
- {
526
- hire_request: config.notifications.notify_on_hire_request,
527
- hire_accepted: config.notifications.notify_on_hire_accepted,
528
- hire_rejected: config.notifications.notify_on_hire_rejected,
529
- delivery: config.notifications.notify_on_delivery,
530
- dispute: config.notifications.notify_on_dispute,
531
- channel_challenge: config.notifications.notify_on_channel_challenge,
532
- low_balance: config.notifications.notify_on_low_balance,
533
- }
534
- );
522
+ const notifier = buildNotifier(config);
535
523
 
536
524
  // ── Step 10: Start relay listener ───────────────────────────────────────
537
525
  const hireListener = new HireListener(config, db, notifier, config.wallet.contract_address);
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Telegram notification module for daemon events.
3
- * Uses Telegram Bot API no external dependencies.
2
+ * Multi-channel notification module for daemon events.
3
+ * Supports Telegram, Discord webhooks, generic webhooks, and email (via nodemailer).
4
4
  */
5
5
  import * as https from "https";
6
6
  import * as http from "http";
7
+ import type { DaemonConfig } from "./config";
7
8
 
8
9
  export type NotifyEvent =
9
10
  | "hire_request"
@@ -16,33 +17,36 @@ export type NotifyEvent =
16
17
  | "daemon_started"
17
18
  | "daemon_stopped";
18
19
 
19
- export interface TelegramConfig {
20
- botToken: string;
21
- chatId: string;
20
+ // ─── Channel interface ────────────────────────────────────────────────────────
21
+
22
+ export interface NotificationChannel {
23
+ send(title: string, body: string): Promise<void>;
22
24
  }
23
25
 
24
- function telegramPost(botToken: string, method: string, body: object): Promise<void> {
26
+ // ─── HTTP helper ──────────────────────────────────────────────────────────────
27
+
28
+ function httpPost(url: string, payload: string, extraHeaders: Record<string, string>): Promise<void> {
25
29
  return new Promise((resolve, reject) => {
26
- const payload = JSON.stringify(body);
27
- const options: https.RequestOptions = {
28
- hostname: "api.telegram.org",
29
- port: 443,
30
- path: `/bot${botToken}/${method}`,
30
+ const parsed = new URL(url);
31
+ const mod = parsed.protocol === "https:" ? https : http;
32
+ const options = {
33
+ hostname: parsed.hostname,
34
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
35
+ path: parsed.pathname + parsed.search,
31
36
  method: "POST",
32
37
  headers: {
33
38
  "Content-Type": "application/json",
34
39
  "Content-Length": Buffer.byteLength(payload),
40
+ ...extraHeaders,
35
41
  },
36
42
  };
37
- const req = https.request(options, (res) => {
38
- let data = "";
39
- res.on("data", (c: Buffer) => { data += c.toString(); });
43
+ const req = mod.request(options, (res) => {
44
+ res.resume();
40
45
  res.on("end", () => {
41
- const parsed = JSON.parse(data) as { ok: boolean; description?: string };
42
- if (!parsed.ok) {
43
- reject(new Error(`Telegram API error: ${parsed.description}`));
44
- } else {
46
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
45
47
  resolve();
48
+ } else {
49
+ reject(new Error(`HTTP ${res.statusCode}`));
46
50
  }
47
51
  });
48
52
  });
@@ -52,16 +56,118 @@ function telegramPost(botToken: string, method: string, body: object): Promise<v
52
56
  });
53
57
  }
54
58
 
59
+ // ─── Telegram channel ─────────────────────────────────────────────────────────
60
+
61
+ export class TelegramChannel implements NotificationChannel {
62
+ constructor(private botToken: string, private chatId: string) {}
63
+
64
+ async send(title: string, body: string): Promise<void> {
65
+ const text = body ? `<b>${title}</b>\n${body}` : `<b>${title}</b>`;
66
+ const payload = JSON.stringify({ chat_id: this.chatId, text, parse_mode: "HTML" });
67
+ const options: https.RequestOptions = {
68
+ hostname: "api.telegram.org",
69
+ port: 443,
70
+ path: `/bot${this.botToken}/sendMessage`,
71
+ method: "POST",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ "Content-Length": Buffer.byteLength(payload),
75
+ },
76
+ };
77
+ await new Promise<void>((resolve, reject) => {
78
+ const req = https.request(options, (res) => {
79
+ let data = "";
80
+ res.on("data", (c: Buffer) => { data += c.toString(); });
81
+ res.on("end", () => {
82
+ const parsed = JSON.parse(data) as { ok: boolean; description?: string };
83
+ if (!parsed.ok) {
84
+ reject(new Error(`Telegram API error: ${parsed.description}`));
85
+ } else {
86
+ resolve();
87
+ }
88
+ });
89
+ });
90
+ req.on("error", reject);
91
+ req.write(payload);
92
+ req.end();
93
+ });
94
+ }
95
+ }
96
+
97
+ // ─── Discord channel ──────────────────────────────────────────────────────────
98
+
99
+ export class DiscordChannel implements NotificationChannel {
100
+ constructor(private webhookUrl: string) {}
101
+
102
+ async send(title: string, body: string): Promise<void> {
103
+ const content = body ? `**${title}**\n${body}` : `**${title}**`;
104
+ await httpPost(this.webhookUrl, JSON.stringify({ content }), {});
105
+ }
106
+ }
107
+
108
+ // ─── Generic webhook channel ──────────────────────────────────────────────────
109
+
110
+ export class WebhookChannel implements NotificationChannel {
111
+ constructor(private url: string, private headers: Record<string, string> = {}) {}
112
+
113
+ async send(title: string, body: string): Promise<void> {
114
+ await httpPost(
115
+ this.url,
116
+ JSON.stringify({ title, body, timestamp: new Date().toISOString() }),
117
+ this.headers
118
+ );
119
+ }
120
+ }
121
+
122
+ // ─── Email channel (optional — requires nodemailer) ───────────────────────────
123
+
124
+ export class EmailChannel implements NotificationChannel {
125
+ constructor(private cfg: {
126
+ smtpHost: string;
127
+ smtpPort: number;
128
+ smtpUser: string;
129
+ smtpPass: string;
130
+ to: string;
131
+ }) {}
132
+
133
+ async send(title: string, body: string): Promise<void> {
134
+ // nodemailer is an optional runtime dependency — load via require to skip
135
+ // compile-time module resolution. Throws a clear message if not installed.
136
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
137
+ let nodemailer: any;
138
+ try {
139
+ // Using Function constructor avoids TypeScript static import analysis.
140
+ nodemailer = (new Function("require", "return require('nodemailer')"))(require);
141
+ } catch {
142
+ throw new Error("nodemailer is not installed. Run: npm install nodemailer");
143
+ }
144
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
145
+ const transport = nodemailer.createTransport({
146
+ host: this.cfg.smtpHost,
147
+ port: this.cfg.smtpPort,
148
+ auth: { user: this.cfg.smtpUser, pass: this.cfg.smtpPass },
149
+ });
150
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
151
+ await transport.sendMail({
152
+ from: this.cfg.smtpUser,
153
+ to: this.cfg.to,
154
+ subject: title,
155
+ text: body,
156
+ });
157
+ }
158
+ }
159
+
160
+ // ─── Notifier ─────────────────────────────────────────────────────────────────
161
+
55
162
  export class Notifier {
56
- private config: TelegramConfig | null;
163
+ private channels: NotificationChannel[];
57
164
  private notifyFlags: Record<NotifyEvent, boolean>;
58
165
 
59
166
  constructor(
60
- botToken: string,
61
- chatId: string,
167
+ channels: NotificationChannel[],
62
168
  flags: Partial<Record<NotifyEvent, boolean>> = {}
63
169
  ) {
64
- this.config = botToken && chatId ? { botToken, chatId } : null;
170
+ this.channels = channels;
65
171
  this.notifyFlags = {
66
172
  hire_request: flags.hire_request ?? true,
67
173
  hire_accepted: flags.hire_accepted ?? true,
@@ -76,82 +182,116 @@ export class Notifier {
76
182
  }
77
183
 
78
184
  isEnabled(): boolean {
79
- return this.config !== null;
185
+ return this.channels.length > 0;
80
186
  }
81
187
 
82
- async send(event: NotifyEvent, message: string): Promise<void> {
83
- if (!this.config) return;
188
+ async send(event: NotifyEvent, title: string, body: string): Promise<void> {
84
189
  if (!this.notifyFlags[event]) return;
85
- try {
86
- await telegramPost(this.config.botToken, "sendMessage", {
87
- chat_id: this.config.chatId,
88
- text: message,
89
- parse_mode: "HTML",
90
- });
91
- } catch (err) {
92
- // Non-fatal — log and continue
93
- process.stderr.write(`[notify] Telegram send failed: ${err}\n`);
94
- }
190
+ await Promise.all(
191
+ this.channels.map(async (ch) => {
192
+ try {
193
+ await ch.send(title, body);
194
+ } catch (err) {
195
+ process.stderr.write(`[notify] Channel send failed: ${err}\n`);
196
+ }
197
+ })
198
+ );
95
199
  }
96
200
 
97
201
  async notifyHireRequest(hireId: string, hirerAddress: string, priceEth: string, capability: string): Promise<void> {
98
202
  const short = hirerAddress.slice(0, 10);
99
- const msg = [
100
- `⚡ <b>Hire Request</b>`,
101
- `ID: <code>${hireId}</code>`,
102
- `From: <code>${short}...</code>`,
203
+ await this.send("hire_request", "Hire Request", [
204
+ `ID: ${hireId}`,
205
+ `From: ${short}...`,
103
206
  `Capability: ${capability || "unspecified"}`,
104
207
  `Price: ${priceEth} ETH`,
105
208
  ``,
106
- `Approve: <code>arc402 daemon approve ${hireId}</code>`,
107
- `Reject: <code>arc402 daemon reject ${hireId}</code>`,
108
- ].join("\n");
109
- await this.send("hire_request", msg);
209
+ `Approve: arc402 daemon approve ${hireId}`,
210
+ `Reject: arc402 daemon reject ${hireId}`,
211
+ ].join("\n"));
110
212
  }
111
213
 
112
214
  async notifyHireAccepted(hireId: string, agreementId: string): Promise<void> {
113
- await this.send("hire_accepted",
114
- `✅ <b>Hire Accepted</b>\nID: <code>${hireId}</code>\nAgreement: <code>${agreementId}</code>`
215
+ await this.send("hire_accepted", "Hire Accepted",
216
+ `ID: ${hireId}\nAgreement: ${agreementId}`
115
217
  );
116
218
  }
117
219
 
118
220
  async notifyHireRejected(hireId: string, reason: string): Promise<void> {
119
- await this.send("hire_rejected",
120
- `❌ <b>Hire Rejected</b>\nID: <code>${hireId}</code>\nReason: ${reason}`
221
+ await this.send("hire_rejected", "Hire Rejected",
222
+ `ID: ${hireId}\nReason: ${reason}`
121
223
  );
122
224
  }
123
225
 
124
226
  async notifyDelivery(agreementId: string, deliveryHash: string, userOpHash: string): Promise<void> {
125
- await this.send("delivery",
126
- `📦 <b>Delivery Submitted</b>\nAgreement: <code>${agreementId}</code>\nDelivery hash: <code>${deliveryHash.slice(0, 16)}...</code>\nUserOp: <code>${userOpHash.slice(0, 16)}...</code>`
127
- );
227
+ await this.send("delivery", "Delivery Submitted", [
228
+ `Agreement: ${agreementId}`,
229
+ `Delivery hash: ${deliveryHash.slice(0, 16)}...`,
230
+ `UserOp: ${userOpHash.slice(0, 16)}...`,
231
+ ].join("\n"));
128
232
  }
129
233
 
130
234
  async notifyDispute(agreementId: string, raisedBy: string): Promise<void> {
131
- await this.send("dispute",
132
- `⚠️ <b>Dispute Raised</b>\nAgreement: <code>${agreementId}</code>\nBy: <code>${raisedBy}</code>`
235
+ await this.send("dispute", "Dispute Raised",
236
+ `Agreement: ${agreementId}\nBy: ${raisedBy}`
133
237
  );
134
238
  }
135
239
 
136
240
  async notifyChannelChallenge(channelId: string, txHash: string): Promise<void> {
137
- await this.send("channel_challenge",
138
- `🔔 <b>Channel Challenged</b>\nChannel: <code>${channelId.slice(0, 16)}...</code>\nTx: <code>${txHash.slice(0, 16)}...</code>`
241
+ await this.send("channel_challenge", "Channel Challenged",
242
+ `Channel: ${channelId.slice(0, 16)}...\nTx: ${txHash.slice(0, 16)}...`
139
243
  );
140
244
  }
141
245
 
142
246
  async notifyLowBalance(balanceEth: string, thresholdEth: string): Promise<void> {
143
- await this.send("low_balance",
144
- `💸 <b>Low Balance Alert</b>\nCurrent: ${balanceEth} ETH\nThreshold: ${thresholdEth} ETH`
247
+ await this.send("low_balance", "Low Balance Alert",
248
+ `Current: ${balanceEth} ETH\nThreshold: ${thresholdEth} ETH`
145
249
  );
146
250
  }
147
251
 
148
252
  async notifyStarted(walletAddress: string, subsystems: string[]): Promise<void> {
149
- await this.send("daemon_started",
150
- `🟢 <b>ARC-402 Daemon Started</b>\nWallet: <code>${walletAddress}</code>\nSubsystems: ${subsystems.join(", ")}`
253
+ await this.send("daemon_started", "ARC-402 Daemon Started",
254
+ `Wallet: ${walletAddress}\nSubsystems: ${subsystems.join(", ")}`
151
255
  );
152
256
  }
153
257
 
154
258
  async notifyStopped(): Promise<void> {
155
- await this.send("daemon_stopped", `🔴 <b>ARC-402 Daemon Stopped</b>`);
259
+ await this.send("daemon_stopped", "ARC-402 Daemon Stopped", "");
156
260
  }
157
261
  }
262
+
263
+ // ─── Factory ──────────────────────────────────────────────────────────────────
264
+
265
+ export function buildNotifier(config: DaemonConfig): Notifier {
266
+ const notif = config.notifications;
267
+ const channels: NotificationChannel[] = [];
268
+
269
+ if (notif.telegram_bot_token && notif.telegram_chat_id) {
270
+ channels.push(new TelegramChannel(notif.telegram_bot_token, notif.telegram_chat_id));
271
+ }
272
+ if (notif.discord?.webhook_url) {
273
+ channels.push(new DiscordChannel(notif.discord.webhook_url));
274
+ }
275
+ if (notif.webhook?.url) {
276
+ channels.push(new WebhookChannel(notif.webhook.url, notif.webhook.headers ?? {}));
277
+ }
278
+ if (notif.email?.smtp_host && notif.email?.smtp_user && notif.email?.to) {
279
+ channels.push(new EmailChannel({
280
+ smtpHost: notif.email.smtp_host,
281
+ smtpPort: notif.email.smtp_port,
282
+ smtpUser: notif.email.smtp_user,
283
+ smtpPass: notif.email.smtp_pass,
284
+ to: notif.email.to,
285
+ }));
286
+ }
287
+
288
+ return new Notifier(channels, {
289
+ hire_request: notif.notify_on_hire_request,
290
+ hire_accepted: notif.notify_on_hire_accepted,
291
+ hire_rejected: notif.notify_on_hire_rejected,
292
+ delivery: notif.notify_on_delivery,
293
+ dispute: notif.notify_on_dispute,
294
+ channel_challenge: notif.notify_on_channel_challenge,
295
+ low_balance: notif.notify_on_low_balance,
296
+ });
297
+ }
package/src/repl.ts CHANGED
@@ -231,25 +231,11 @@ class TUI {
231
231
  private setupScreen(): void {
232
232
  this.bannerLines = getBannerLines(this.bannerCfg);
233
233
 
234
- write(ansi.hideCursor);
235
- write(ansi.clearScreen + ansi.home);
236
-
237
- // Banner
234
+ // Simple setup — print banner once, no scroll regions (breaks on macOS Terminal)
238
235
  for (const line of this.bannerLines) {
239
236
  write(line + "\n");
240
237
  }
241
-
242
- // Separator between banner and output area
243
- write(chalk.dim("─".repeat(this.termCols)) + "\n");
244
-
245
- // Set scroll region (output area, leaves last row free for input)
246
- if (this.scrollTop <= this.scrollBot) {
247
- write(ansi.scrollRegion(this.scrollTop, this.scrollBot));
248
- }
249
-
250
- // Position cursor at top of output area
251
- write(ansi.move(this.scrollTop, 1));
252
- write(ansi.showCursor);
238
+ write(chalk.dim("─".repeat(Math.min(this.termCols, 60))) + "\n\n");
253
239
  }
254
240
 
255
241
  // ── Banner repaint (in-place, preserves output area) ────────────────────────
@@ -664,10 +650,11 @@ class TUI {
664
650
  if (buffer.trim()) flushLine(buffer);
665
651
  }
666
652
 
667
- // ── After each command: repaint banner + input ───────────────────────────────
653
+ // ── After each command: show prompt ──────────────────────────────────────────
668
654
 
669
655
  private afterCommand(): void {
670
- this.repaintBanner();
656
+ // Simple prompt — no banner repaint (causes scrollback issues on macOS Terminal)
657
+ write("\n");
671
658
  this.drawInputLine();
672
659
  }
673
660