openclacky 1.0.2 → 1.0.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.
@@ -4,9 +4,10 @@ description: |
4
4
  Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
5
5
  Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
6
6
  Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "channel config",
7
- "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor".
8
- Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor.
9
- argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor"
7
+ "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
8
+ "send message to weixin", "send message to feishu", "send message to wecom".
9
+ Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
10
+ argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
10
11
  allowed-tools:
11
12
  - Bash
12
13
  - Read
@@ -33,6 +34,7 @@ Configure IM platform channels for openclacky.
33
34
  | `channel disable feishu/wecom/weixin` | disable |
34
35
  | `channel reconfigure` | reconfigure |
35
36
  | `channel doctor` | doctor |
37
+ | `send <message> to weixin/feishu/wecom` | send |
36
38
 
37
39
  ---
38
40
 
@@ -347,6 +349,58 @@ Check each item, report ✅ / ❌ with remediation:
347
349
 
348
350
  ---
349
351
 
352
+ ## `send`
353
+
354
+ Proactively send a message to a user via an IM channel adapter.
355
+
356
+ ### Parse the request
357
+
358
+ Extract two things from the user's instruction:
359
+ - **platform** — one of `weixin`, `feishu`, `wecom`
360
+ - **message** — the text content to send
361
+
362
+ If the platform cannot be inferred, ask the user to clarify.
363
+
364
+ ### Step 1 — Resolve target user (optional)
365
+
366
+ If the user specified a `user_id`, use it directly.
367
+
368
+ Otherwise, list known users first:
369
+
370
+ ```bash
371
+ curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/users
372
+ ```
373
+
374
+ - If the list is **empty**: tell the user "No known users for `<platform>`. The target user must send at least one message to the bot before proactive messaging is possible." Stop here.
375
+ - If there is **exactly one** user: use it silently.
376
+ - If there are **multiple** users: show the list and ask which one to send to, unless the user already specified one.
377
+
378
+ ### Step 2 — Send the message
379
+
380
+ ```bash
381
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/send \
382
+ -H "Content-Type: application/json" \
383
+ -d '{"message": "<message>", "user_id": "<user_id>"}'
384
+ ```
385
+
386
+ **Response handling:**
387
+
388
+ | HTTP status | Meaning | Action |
389
+ |---|---|---|
390
+ | `200 { ok: true }` | Delivered | Tell user: "✅ Message sent to `<platform>`." |
391
+ | `400` platform not running | Adapter is stopped | Tell user the platform is not running and suggest `channel enable <platform>`. |
392
+ | `400` no context_token | Token missing | Explain: "The bot has no active session token for this user. Ask the user to send any message to the bot first, then retry." |
393
+ | `503` no known users | Nobody has messaged the bot | Same guidance as empty user list above. |
394
+ | Other error | Unexpected | Show the error message from the response body. |
395
+
396
+ ### Constraints & notes
397
+
398
+ - **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
399
+ - **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
400
+ - This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
401
+
402
+ ---
403
+
350
404
  ## Security
351
405
 
352
406
  - Always mask secrets in output (last 4 chars only).
@@ -393,6 +393,13 @@ module Clacky
393
393
  @ctx_mutex.synchronize { @context_tokens[user_id] }
394
394
  end
395
395
 
396
+ # Return all user IDs for which we have a cached context_token.
397
+ # Used by ChannelManager#known_users so callers can enumerate
398
+ # users reachable for proactive messaging.
399
+ def context_token_user_ids
400
+ @ctx_mutex.synchronize { @context_tokens.keys.dup }
401
+ end
402
+
396
403
  # Split text into ≤2000 Unicode character chunks per iLink protocol recommendation.
397
404
  # Priority: split at \n\n, then \n, then space, then hard cut.
398
405
  def split_message(text, limit: 2000)
@@ -77,6 +77,74 @@ module Clacky
77
77
  @mutex.synchronize { @adapters.map(&:platform_id) }
78
78
  end
79
79
 
80
+ # Proactively send a message to a user on the given platform.
81
+ #
82
+ # For Weixin (iLink protocol) a context_token is required for every outbound
83
+ # message. This method looks up the most-recently cached token for user_id.
84
+ # If no token is found the message cannot be delivered and nil is returned.
85
+ #
86
+ # For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
87
+ #
88
+ # @param platform [Symbol, String] e.g. :weixin, :feishu, :wecom
89
+ # @param user_id [String] IM user identifier
90
+ # @param message [String] plain-text (or markdown) message to send
91
+ # @return [Hash, nil] adapter result hash, or nil on failure
92
+ def send_to_user(platform, user_id, message)
93
+ platform = platform.to_sym
94
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
95
+
96
+ unless adapter
97
+ Clacky::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
98
+ return nil
99
+ end
100
+
101
+ Clacky::Logger.info("[ChannelManager] send_to_user :#{platform} → #{user_id}")
102
+ adapter.send_text(user_id, message)
103
+ rescue StandardError => e
104
+ Clacky::Logger.error("[ChannelManager] send_to_user failed: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Return a list of known user IDs for the given platform.
109
+ # Collected from every message that has been processed since the server started.
110
+ # Weixin stores context_tokens keyed by user_id; feishu/wecom track chat_ids
111
+ # via the session binding table in the registry.
112
+ #
113
+ # @param platform [Symbol, String]
114
+ # @return [Array<String>]
115
+ def known_users(platform)
116
+ platform = platform.to_sym
117
+ adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
118
+ return [] unless adapter
119
+
120
+ # Weixin adapter exposes @context_tokens whose keys are user_ids
121
+ if adapter.respond_to?(:context_token_user_ids)
122
+ return adapter.context_token_user_ids
123
+ end
124
+
125
+ # Fallback: scan session registry for channel_keys matching this platform.
126
+ # Key formats depend on binding_mode:
127
+ # :user → "platform:user:USER_ID"
128
+ # :chat → "platform:chat:CHAT_ID"
129
+ # :chat_user → "platform:chat:CHAT_ID:user:USER_ID"
130
+ #
131
+ # For send_text we need the chat_id (Feishu/WeCom use chat_id as the
132
+ # receive_id for outbound messages), so we extract the chat portion.
133
+ prefix = "#{platform}:"
134
+ ids = []
135
+ @registry.list.each do |summary|
136
+ @registry.with_session(summary[:id]) do |s|
137
+ (s[:channel_keys] || []).each do |key|
138
+ next unless key.start_with?(prefix)
139
+
140
+ remainder = key.sub(prefix, "") # e.g. "chat:OC_ID:user:OU_ID" or "user:UID" or "chat:CID"
141
+ ids << extract_chat_id(remainder)
142
+ end
143
+ end
144
+ end
145
+ ids.compact.uniq
146
+ end
147
+
80
148
  # Hot-reload a single platform adapter with updated config.
81
149
  # Stops the existing adapter (if running), then starts a new one if enabled.
82
150
  # @param platform [Symbol]
@@ -367,6 +435,29 @@ module Clacky
367
435
  end
368
436
  end
369
437
 
438
+ # Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
439
+ #
440
+ # Possible formats:
441
+ # "chat:CHAT_ID:user:USER_ID" → CHAT_ID (chat_user mode)
442
+ # "chat:CHAT_ID" → CHAT_ID (chat mode)
443
+ # "user:USER_ID" → USER_ID (user mode — use user_id as fallback)
444
+ #
445
+ # For Feishu/WeCom send_text, the chat_id is what's needed as receive_id.
446
+ private def extract_chat_id(remainder)
447
+ if remainder.start_with?("chat:")
448
+ # "chat:CHAT_ID:user:USER_ID" or "chat:CHAT_ID"
449
+ after_chat = remainder.sub("chat:", "")
450
+ # If there's a ":user:" segment, strip it and everything after
451
+ idx = after_chat.index(":user:")
452
+ idx ? after_chat[0...idx] : after_chat
453
+ elsif remainder.start_with?("user:")
454
+ # user-only mode: no chat_id available, use user_id
455
+ remainder.sub("user:", "")
456
+ else
457
+ remainder
458
+ end
459
+ end
460
+
370
461
  def safe_stop_adapter(adapter)
371
462
  adapter.stop
372
463
  rescue StandardError => e
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "socket"
5
+
6
+ module Clacky
7
+ module Server
8
+ # Discover locally-running Clacky server(s) by scanning PID files
9
+ # written by Master at /tmp/clacky-master-<port>.pid.
10
+ #
11
+ # Used by the CLI (bare `clacky agent` mode) to auto-detect a sibling
12
+ # server process, so skills that call back into the server (channels,
13
+ # browser, scheduler, etc.) can work without the user manually setting
14
+ # CLACKY_SERVER_HOST / CLACKY_SERVER_PORT.
15
+ #
16
+ # Fast and side-effect free: only reads files and sends signal 0.
17
+ # Does NOT probe the TCP port (avoids false positives from stale files
18
+ # but also avoids noisy connection attempts).
19
+ module Discover
20
+ PID_FILE_GLOB = File.join(Dir.tmpdir, "clacky-master-*.pid").freeze
21
+ PID_FILE_REGEX = /clacky-master-(\d+)\.pid\z/.freeze
22
+
23
+ module_function
24
+
25
+ # Find the first live Clacky server on this machine.
26
+ #
27
+ # @return [Hash, nil] { host: "127.0.0.1", port: Integer, pid: Integer } or nil
28
+ def find_local
29
+ find_all_local.first
30
+ end
31
+
32
+ # Find all live Clacky servers on this machine.
33
+ #
34
+ # A PID file is considered "live" when:
35
+ # 1. The filename matches clacky-master-<port>.pid
36
+ # 2. Its contents parse as a positive integer
37
+ # 3. Process.kill(0, pid) confirms the PID is alive
38
+ #
39
+ # Stale PID files (process gone) are silently ignored. We do NOT
40
+ # delete them here — that's the owning server's responsibility.
41
+ #
42
+ # @return [Array<Hash>] sorted by port ascending
43
+ def find_all_local
44
+ Dir.glob(PID_FILE_GLOB).filter_map do |path|
45
+ m = path.match(PID_FILE_REGEX)
46
+ next nil unless m
47
+
48
+ port = m[1].to_i
49
+ next nil if port <= 0
50
+
51
+ pid_str = File.read(path).strip rescue nil
52
+ next nil if pid_str.nil? || pid_str.empty?
53
+
54
+ pid = pid_str.to_i
55
+ next nil if pid <= 0
56
+
57
+ next nil unless process_alive?(pid)
58
+
59
+ { host: "127.0.0.1", port: port, pid: pid }
60
+ end.sort_by { |e| e[:port] }
61
+ end
62
+
63
+ # @param pid [Integer]
64
+ # @return [Boolean]
65
+ def process_alive?(pid)
66
+ Process.kill(0, pid)
67
+ true
68
+ rescue Errno::ESRCH, Errno::EPERM
69
+ # ESRCH — no such process; EPERM — process exists but owned by someone else
70
+ # (still technically alive, but we can't safely assume it's "our" server)
71
+ false
72
+ rescue StandardError
73
+ false
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Clacky
6
+ module Server
7
+ # EPIPESafeIO — wraps an IO ($stdout / $stderr) so that writes never raise
8
+ # Errno::EPIPE / IOError to the calling code.
9
+ #
10
+ # Why this exists:
11
+ # The server worker process inherits fd 0/1/2 from the Master. If the
12
+ # Master itself was launched in a way where its stdout/stderr eventually
13
+ # becomes a broken pipe (e.g. launched by an installer that exits, or by
14
+ # a GUI/IDE process that closes its end), the worker's first `puts` after
15
+ # that pipe breaks raises Errno::EPIPE. Unhandled, this kills the worker
16
+ # — taking all in-memory sessions, agent loops, and SSE connections down
17
+ # with it, and triggering a crash loop because the new worker inherits
18
+ # the same broken fd.
19
+ #
20
+ # Behavior:
21
+ # - Healthy state: delegates every method to the underlying IO. Users
22
+ # see normal terminal output (banner, request logs, etc.).
23
+ # - First broken-pipe failure: catches Errno::EPIPE / IOError, swaps
24
+ # the underlying IO to /dev/null permanently, and returns silently.
25
+ # Subsequent writes succeed (into /dev/null) so the worker stays alive.
26
+ # - Session state, agent loops, SSE connections all preserved.
27
+ #
28
+ # Scope:
29
+ # We only wrap $stdout / $stderr (the global variables that Kernel#puts,
30
+ # Kernel#print, Kernel#warn, etc. use under the hood). We do NOT touch
31
+ # the STDOUT / STDERR constants — a codebase audit confirmed nothing in
32
+ # Clacky writes to those constants directly (only `STDOUT.flush` which
33
+ # cannot raise EPIPE).
34
+ class EPIPESafeIO < SimpleDelegator
35
+ # Methods that perform writes and may raise Errno::EPIPE.
36
+ # We override each one to rescue and degrade gracefully.
37
+ WRITE_METHODS = %i[write write_nonblock syswrite puts print printf << putc].freeze
38
+
39
+ WRITE_METHODS.each do |m|
40
+ define_method(m) do |*args, **kwargs, &blk|
41
+ if kwargs.empty?
42
+ __getobj__.public_send(m, *args, &blk)
43
+ else
44
+ __getobj__.public_send(m, *args, **kwargs, &blk)
45
+ end
46
+ rescue Errno::EPIPE, IOError => e
47
+ fall_back_to_null!(e)
48
+ # Retry the write into /dev/null so semantics (return value type) stay
49
+ # close to what the caller expects. If even this fails, swallow it —
50
+ # we must not raise from inside a write to $stdout/$stderr.
51
+ begin
52
+ if kwargs.empty?
53
+ __getobj__.public_send(m, *args, &blk)
54
+ else
55
+ __getobj__.public_send(m, *args, **kwargs, &blk)
56
+ end
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+
63
+ # Some callers do `$stdout.flush`. Make it safe too.
64
+ def flush
65
+ __getobj__.flush
66
+ rescue Errno::EPIPE, IOError => e
67
+ fall_back_to_null!(e)
68
+ nil
69
+ end
70
+
71
+ # Whether this wrapper has already fallen back to /dev/null.
72
+ # Useful for tests and diagnostics.
73
+ def fell_back?
74
+ @fell_back == true
75
+ end
76
+
77
+ private def fall_back_to_null!(error)
78
+ return if @fell_back
79
+
80
+ @fell_back = true
81
+ begin
82
+ # Best-effort: try to log once via Clacky::Logger if available.
83
+ # Wrapped in rescue because Logger itself might be mid-init.
84
+ if defined?(Clacky::Logger)
85
+ Clacky::Logger.warn(
86
+ "[EPIPESafeIO] Underlying IO broken (#{error.class}: #{error.message}); " \
87
+ "falling back to /dev/null. Worker stays alive."
88
+ )
89
+ end
90
+ rescue StandardError
91
+ # ignore
92
+ end
93
+
94
+ begin
95
+ null = File.open(File::NULL, "w")
96
+ null.sync = true
97
+ __setobj__(null)
98
+ rescue StandardError
99
+ # If even opening /dev/null fails, leave the original object — at
100
+ # worst the next write raises again and we rescue again.
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -203,15 +203,6 @@ module Clacky
203
203
 
204
204
  Clacky::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
205
205
 
206
- # In standalone mode (no master), kill any stale server and manage our own PID file.
207
- # In worker mode the master owns the PID file; we just skip this block.
208
- if @inherited_socket.nil?
209
- kill_existing_server(@port)
210
- pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
211
- File.write(pid_file, Process.pid.to_s)
212
- at_exit { File.delete(pid_file) if File.exist?(pid_file) }
213
- end
214
-
215
206
  # Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
216
207
  # so they can call back into the server without hardcoding the port,
217
208
  # and use the correct product name without re-reading brand.yml.
@@ -412,7 +403,13 @@ module Clacky
412
403
  when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
413
404
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
414
405
  else
415
- if method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
406
+ if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
407
+ platform = path.sub("/api/channels/", "").sub("/send", "")
408
+ api_send_channel_message(platform, req, res)
409
+ elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
410
+ platform = path.sub("/api/channels/", "").sub("/users", "")
411
+ api_list_channel_users(platform, res)
412
+ elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
416
413
  platform = path.sub("/api/channels/", "").sub("/test", "")
417
414
  api_test_channel(platform, req, res)
418
415
  elsif method == "POST" && path.start_with?("/api/channels/")
@@ -1439,6 +1436,79 @@ module Clacky
1439
1436
  json_response(res, 200, { channels: platforms })
1440
1437
  end
1441
1438
 
1439
+ # POST /api/channels/:platform/send
1440
+ # Proactively send a message to a user via the given IM platform.
1441
+ #
1442
+ # Body:
1443
+ # { "message": "hello", # required
1444
+ # "user_id": "some_user_id" } # optional — defaults to most-recently active user
1445
+ #
1446
+ # Response:
1447
+ # 200 { ok: true }
1448
+ # 400 { ok: false, error: "..." } — missing/invalid params or platform not running
1449
+ # 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
1450
+ #
1451
+ # Constraints:
1452
+ # - The platform adapter must be running (channel must be enabled + connected).
1453
+ # - For Weixin (iLink protocol), a context_token is required per message. This is
1454
+ # automatically looked up from the in-memory cache populated by inbound messages.
1455
+ # If no token exists for the target user (i.e. the user has never messaged the bot
1456
+ # in this server session), the message cannot be delivered.
1457
+ def api_send_channel_message(platform, req, res)
1458
+ platform = platform.to_sym
1459
+ body = parse_json_body(req)
1460
+ message = body["message"].to_s.strip
1461
+
1462
+ if message.empty?
1463
+ json_response(res, 400, { ok: false, error: "message is required" })
1464
+ return
1465
+ end
1466
+
1467
+ # Resolve target user_id
1468
+ user_id = body["user_id"].to_s.strip
1469
+ if user_id.empty?
1470
+ # Default to the most-recently active user for this platform
1471
+ known = @channel_manager.known_users(platform)
1472
+ if known.empty?
1473
+ json_response(res, 503, {
1474
+ ok: false,
1475
+ error: "No known users for :#{platform}. The user must send a message to the bot first."
1476
+ })
1477
+ return
1478
+ end
1479
+ user_id = known.last
1480
+ end
1481
+
1482
+ result = @channel_manager.send_to_user(platform, user_id, message)
1483
+ if result.nil?
1484
+ json_response(res, 400, {
1485
+ ok: false,
1486
+ error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
1487
+ })
1488
+ else
1489
+ json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
1490
+ end
1491
+ rescue StandardError => e
1492
+ json_response(res, 500, { ok: false, error: e.message })
1493
+ end
1494
+
1495
+ # GET /api/channels/:platform/users
1496
+ # Returns the list of known user IDs for the given platform.
1497
+ # These are users who have sent at least one message to the bot in this server session.
1498
+ #
1499
+ # For Weixin: returns users with a cached context_token (required for proactive messaging).
1500
+ # For Feishu / WeCom: returns user IDs extracted from channel session bindings.
1501
+ #
1502
+ # Response:
1503
+ # 200 { users: ["uid1", "uid2", ...] }
1504
+ def api_list_channel_users(platform, res)
1505
+ platform = platform.to_sym
1506
+ users = @channel_manager.known_users(platform)
1507
+ json_response(res, 200, { platform: platform, users: users })
1508
+ rescue StandardError => e
1509
+ json_response(res, 500, { ok: false, error: e.message })
1510
+ end
1511
+
1442
1512
  # POST /api/upload
1443
1513
  # Accepts a multipart/form-data file upload (field name: "file").
1444
1514
  # Runs the file through FileProcessor: saves original + generates structured
@@ -3648,36 +3718,6 @@ module Clacky
3648
3718
  res.body = "Not Found"
3649
3719
  end
3650
3720
 
3651
- # Stop any previously running server on the given port via its PID file.
3652
- private def kill_existing_server(port)
3653
- pid_file = File.join(Dir.tmpdir, "clacky-server-#{port}.pid")
3654
- return unless File.exist?(pid_file)
3655
-
3656
- pid = File.read(pid_file).strip.to_i
3657
- return if pid <= 0
3658
- # After exec-restart, the new process inherits the same PID as the old one.
3659
- # Skip sending TERM to ourselves — we are already the new server.
3660
- if pid == Process.pid
3661
- Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
3662
- return
3663
- end
3664
-
3665
- begin
3666
- Process.kill("TERM", pid)
3667
- Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
3668
- puts "Stopped existing server (PID: #{pid}) on port #{port}."
3669
- # Give it a moment to release the port
3670
- sleep 0.5
3671
- rescue Errno::ESRCH
3672
- Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
3673
- rescue Errno::EPERM
3674
- Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
3675
- puts "Could not stop existing server (PID: #{pid}) — permission denied."
3676
- ensure
3677
- File.delete(pid_file) if File.exist?(pid_file)
3678
- end
3679
- end
3680
-
3681
3721
  # ── Inner classes ─────────────────────────────────────────────────────────
3682
3722
 
3683
3723
  # Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
@@ -143,10 +143,16 @@ module Clacky
143
143
 
144
144
  Clacky::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
145
145
  Clacky::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
146
+
146
147
  # pgroup: 0 puts worker in its own process group.
147
148
  # This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
148
149
  # ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
149
150
  # cleaned up even if the worker is force-killed before its shutdown_proc runs.
151
+ #
152
+ # NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
153
+ # so users see startup banner / request logs in their terminal. Protection
154
+ # against Errno::EPIPE on broken parent stdout is installed inside the
155
+ # worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
150
156
  pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
151
157
  Clacky::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
152
158
  pid
data/lib/clacky/skill.rb CHANGED
@@ -287,6 +287,36 @@ module Clacky
287
287
  end
288
288
  end
289
289
 
290
+ # Environment hint: if the skill references ${CLACKY_SERVER_HOST/PORT} but
291
+ # those vars were not injected (bare-CLI mode without a running server),
292
+ # the `${...}` placeholders will survive expansion as literal text. In that
293
+ # case append a non-fatal note so the LLM knows the skill's HTTP callbacks
294
+ # will not work, without blocking the skill entirely (the user may still
295
+ # want to read instructions, explore files, etc.).
296
+ if processed_content.match?(/\$\{CLACKY_SERVER_(HOST|PORT)\}/)
297
+ processed_content += <<~HINT
298
+
299
+
300
+ ---
301
+
302
+ > ⚠️ **Environment note (auto-injected)**: this skill calls back into the
303
+ > Clacky HTTP server (via `${CLACKY_SERVER_HOST}` / `${CLACKY_SERVER_PORT}`),
304
+ > but those variables are **not set** in the current process. That means
305
+ > no local Clacky server was detected.
306
+ >
307
+ > Any `curl http://${CLACKY_SERVER_HOST}:...` command in the steps above
308
+ > will fail with a DNS/connection error. Before running those steps you
309
+ > should either:
310
+ >
311
+ > 1. Ask the user to start the server in another terminal: `clacky server`
312
+ > (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
313
+ > 2. If the task can be completed without the server API, skip those steps
314
+ > and tell the user which parts require the server.
315
+ >
316
+ > This is an informational hint, not an error. Proceed with judgment.
317
+ HINT
318
+ end
319
+
290
320
  processed_content
291
321
  end
292
322
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.3"
5
5
  end