openclacky 0.9.24 → 0.9.26

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5338bcdeaf4b2aafed416365f56467a99a6107911b24df2bfe9d94d757d03528
4
- data.tar.gz: 8e06f6ca6b0d5a0a9c70ab758fc13b2f84d755f7d61e30a9708fdadd4db82309
3
+ metadata.gz: 65dad1ba4790fcffb30157bcc74a05a9f9cbd3e341ead0e007e6c768b0c11fd1
4
+ data.tar.gz: f507ade251d206b073eb1b88c236d8905e9c986718d93c62cf3f2b575fa908bb
5
5
  SHA512:
6
- metadata.gz: 7862cd557ea5ba9e88184b314fd88fc820d1f4e0c5a9bbebbfcdee75f33301876aff7df7f13cf07eaa21c7fb47d0efa65e7f051c1641ced6c20d1142e9e52819
7
- data.tar.gz: b33e3cc3331e70328d366a38b67e472c760eb6bbe4b5621532ee4ae60585303d9042c4953f29f31e4256f357765d8aafee2b3e69fa87f31c386f9310be790c46
6
+ metadata.gz: f70df4500b95cd35c2a3fb245e384f94bbf4051da6528d6b9bd76a9a38e81838844cc68276ab0b7fc3833982252b9d61c1c5c3b414e22422049e2a91f2c24a4b
7
+ data.tar.gz: 6cd678e1288d06f6997c01c056b898c900ba9b7fcde2bacdd3a35b113d0ab4d33d3e5e2c2fde9a31d1c9c351b615b93f28bcfa305eb7489b4deda1c1c801ce45
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.26] - 2026-04-03
11
+
12
+ ### Added
13
+ - **Long-running shell output streaming**: shell commands that run for a long time now stream output progressively to the Web UI instead of waiting until completion — no more blank screen for slow commands
14
+
15
+ ### Fixed
16
+ - **Session rename for non-active sessions**: renaming a session that isn't currently active now updates immediately in the sidebar (previously required a page refresh)
17
+ - **Feishu channel setup timeout**: increased timeout to 180s to prevent setup failures on slow networks
18
+ - **WSL browser setup tip**: improved browser-setup skill instructions for WSL environments
19
+ - **ARM install mirror**: install scripts now correctly use the Aliyun mirror on ARM machines
20
+
21
+ ## [0.9.25] - 2026-04-02
22
+
23
+ ### Added
24
+ - **CSV file upload support**: you can now upload `.csv` files in the Web UI — agent can read and analyse tabular data directly
25
+ - **Browser install tips**: when a browser-dependent command fails, the agent now shows a clear install tip with instructions to set up Chrome/Edge, rather than a cryptic error
26
+ - **Auto-focus on file upload dialog**: the file input field is now auto-focused when the upload dialog opens, improving keyboard UX
27
+ - **Session ID search in Web UI**: you can now search sessions by session ID in addition to session name
28
+
29
+ ### Fixed
30
+ - **WeChat (Weixin) file upload**: fixed a bug where file attachments sent via WeChat were not correctly forwarded to the agent
31
+ - **WeChat without browser**: WeChat channel now works even when no browser tool is configured — falls back gracefully
32
+ - **API message timeout**: fixed a race condition in message compression / session serialisation that could cause API requests to time out mid-conversation
33
+ - **Session chunk replay**: fixed a bug where streaming (chunk-based) messages were incorrectly replayed when restoring a session
34
+
35
+ ### Improved
36
+ - **Shell tool robustness**: `pkill` commands are now scope-limited to prevent accidental process kills; server process cleans up properly when the terminal is closed
37
+ - **Broken pipe handling**: improved error handling in the HTTP server and shell tool to avoid noisy broken-pipe errors on abrupt connection close
38
+
39
+ ### More
40
+ - Updated product-help skill with new session search and CSV upload documentation
41
+ - Updated channel-setup skill with improved WeChat non-browser setup guide
42
+
10
43
  ## [0.9.24] - 2026-04-02
11
44
 
12
45
  ### Added
@@ -128,7 +128,11 @@ module Clacky
128
128
  original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
129
129
 
130
130
  # Archive compressed messages to a chunk MD file before discarding them
131
- chunk_index = @compressed_summaries.size + 1
131
+ # Count existing compressed_summary messages in history to determine the next chunk index.
132
+ # Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
133
+ # chunk files, creating circular chunk references. Counting from history is always accurate.
134
+ existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
135
+ chunk_index = existing_chunk_count + 1
132
136
  chunk_path = save_compressed_chunk(
133
137
  original_messages,
134
138
  compression_context[:recent_messages],
@@ -152,6 +152,16 @@ module Clacky
152
152
  # Compressed summary sitting before any user rounds — expand it from chunk md
153
153
  chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
154
154
  rounds.concat(chunk_rounds)
155
+ # After expanding, treat the last chunk round as the current round so that
156
+ # any orphaned assistant/tool messages that follow in session.json (belonging
157
+ # to the same task whose user message was compressed into the chunk) get
158
+ # appended here instead of being silently discarded.
159
+ current_round = rounds.last
160
+ elsif rounds.last
161
+ # Orphaned non-user message with no current_round yet (e.g. recent_messages
162
+ # after compression started mid-task with no leading user message).
163
+ # Attach to the last known round rather than drop silently.
164
+ rounds.last[:events] << msg
155
165
  end
156
166
  end
157
167
 
@@ -211,10 +221,27 @@ module Clacky
211
221
  #
212
222
  # @param chunk_path [String] Path to the chunk md file
213
223
  # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
214
- private def parse_chunk_md_to_rounds(chunk_path)
215
- return [] unless chunk_path && File.exist?(chunk_path.to_s)
224
+ private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
225
+ return [] unless chunk_path
226
+
227
+ # 1. Try the stored absolute path first (same machine, normal case).
228
+ # 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
229
+ resolved = chunk_path.to_s
230
+ unless File.exist?(resolved)
231
+ resolved = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(resolved))
232
+ end
233
+
234
+ return [] unless File.exist?(resolved)
235
+
236
+ # Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
237
+ canonical = File.expand_path(resolved)
238
+ if visited.include?(canonical)
239
+ Clacky::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
240
+ return []
241
+ end
242
+ visited = visited.dup.add(canonical)
216
243
 
217
- raw = File.read(chunk_path.to_s, encoding: "utf-8")
244
+ raw = File.read(resolved)
218
245
 
219
246
  # Parse YAML front matter to get archived_at for synthetic timestamps
220
247
  archived_at = nil
@@ -275,7 +302,7 @@ module Clacky
275
302
 
276
303
  # Nested chunk: expand it recursively, prepend before current rounds
277
304
  if sec[:nested_chunk]
278
- nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
305
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
279
306
  rounds = nested + rounds unless nested.empty?
280
307
  # Also render its summary text as an assistant event in current round if any
281
308
  if current_round && !text.empty?
data/lib/clacky/agent.rb CHANGED
@@ -662,6 +662,20 @@ module Clacky
662
662
  sleep 2
663
663
  @ui.show_progress(progress_message, prefix_newline: false, output_buffer: output_buffer)
664
664
  progress_shown = true
665
+
666
+ # For shell commands: stream new stdout lines to WebUI as they arrive
667
+ if output_buffer && @ui.respond_to?(:show_tool_stdout)
668
+ last_sent_count = 0
669
+ loop do
670
+ sleep 1
671
+ stdout_lines = output_buffer[:stdout_lines]&.to_a || []
672
+ new_lines = stdout_lines[last_sent_count..]
673
+ if new_lines && !new_lines.empty?
674
+ @ui.show_tool_stdout(new_lines)
675
+ last_sent_count = stdout_lines.size
676
+ end
677
+ end
678
+ end
665
679
  end
666
680
  end
667
681
 
@@ -79,15 +79,25 @@ Tell the user:
79
79
  > I've opened `chrome://inspect/#remote-debugging` in your browser.
80
80
  > Please click **"Allow remote debugging for this browser instance"** and let me know when done.
81
81
 
82
- If `open` fails (e.g. on WSL or Linux), fall back to:
82
+ If `open` fails (e.g. on WSL or Linux):
83
+
84
+ **On WSL**, guide the user in simple steps (no PowerShell commands needed):
85
+
86
+ > To enable remote debugging, please follow these steps:
87
+ >
88
+ > 1. Open **Edge** on Windows
89
+ > 2. Type the following in the address bar and press Enter:
90
+ > ```
91
+ > edge://inspect/#remote-debugging
92
+ > ```
93
+ > 3. Click **"Allow remote debugging for this browser instance"**
94
+ > 4. Let me know when done and I'll continue ✅
95
+
96
+ **On Linux (non-WSL)**:
83
97
 
84
98
  > Please open this URL in Chrome or Edge:
85
99
  > `chrome://inspect/#remote-debugging`
86
100
  > Then click **"Allow remote debugging for this browser instance"** and let me know when done.
87
- >
88
- > On WSL: launch your browser from PowerShell with remote debugging enabled:
89
- > - Edge: `Start-Process msedge --ArgumentList "--remote-debugging-port=9222"`
90
- > - Chrome: `Start-Process chrome --ArgumentList "--remote-debugging-port=9222"`
91
101
 
92
102
  Wait for the user to confirm, then retry the connection once. If still failing, stop:
93
103
 
@@ -92,6 +92,7 @@ Run the setup script (full path is available in the supporting files list above)
92
92
  ```bash
93
93
  ruby "SKILL_DIR/feishu_setup.rb"
94
94
  ```
95
+ **Important**: call `safe_shell` with `timeout: 180` — the script may wait up to 90s for a WebSocket connection in Phase 4.
95
96
 
96
97
  **If exit code is 0:**
97
98
  - The script completed successfully.
@@ -206,7 +207,7 @@ On success: "✅ WeCom channel configured. WeCom client → Contacts → Smart B
206
207
 
207
208
  Weixin uses a QR code login — no app_id/app_secret needed. The token from the QR scan is saved directly in `channels.yml`.
208
209
 
209
- #### Step 1 — Fetch QR code and open in browser
210
+ #### Step 1 — Fetch QR code
210
211
 
211
212
  Run the script in `--fetch-qr` mode to get the QR URL without blocking:
212
213
 
@@ -221,17 +222,29 @@ Parse the JSON output:
221
222
 
222
223
  If the output contains `"error"`, show it and stop.
223
224
 
224
- Tell the user:
225
- > Opening the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
225
+ #### Step 2 — Show QR code to user (browser or manual fallback)
226
226
 
227
- **Open the QR code page in browser** build a local URL and navigate to it:
227
+ Build the local QR page URL (include current Unix timestamp as `since` to detect new logins only):
228
+ ```
229
+ http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>&since=<current_unix_timestamp>
230
+ ```
228
231
 
232
+ **Try browser first** — attempt to open the QR page using the browser tool:
229
233
  ```
230
- http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>
234
+ browser(action="navigate", url="<qr_page_url>")
231
235
  ```
232
236
 
233
- Use the browser tool to open this URL. The page renders a proper scannable QR code image using qrcode.js.
234
- Do NOT open the raw `qrcode_url` directly that page shows "请使用微信扫码打开" with no actual QR image.
237
+ **If browser succeeds:** Tell the user:
238
+ > I've opened the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
239
+
240
+ **If browser fails (not configured or unavailable):** Fall back to manual — tell the user:
241
+ > Please open the following link in your browser to scan the WeChat QR code:
242
+ >
243
+ > `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
244
+ >
245
+ > Scan the QR code with WeChat, confirm in the app, then reply "done".
246
+
247
+ The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
235
248
 
236
249
  #### Step 3 — Wait for scan and save credentials
237
250
 
@@ -243,7 +256,11 @@ ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
243
256
 
244
257
  Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
245
258
 
246
- This command blocks until the user scans and confirms in WeChat (up to 5 minutes), then automatically saves the token via `POST /api/channels/weixin`.
259
+ Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
260
+ - Exit code is 0 → success
261
+ - Output contains "expired" → QR expired, offer to restart from Step 1
262
+ - Output contains "timed out" → offer to restart from Step 1
263
+ - 3 retries exhausted → show error and offer to restart from Step 1
247
264
 
248
265
  Tell the user while waiting:
249
266
  > Waiting for you to scan the QR code and confirm in WeChat... (this may take a moment)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: product-help
3
- description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, or troubleshooting. Do NOT trigger for general coding tasks unrelated to me.'
3
+ description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, troubleshooting, or restarting the server. Do NOT trigger for general coding tasks unrelated to me.'
4
4
  fork_agent: true
5
5
  user-invocable: false
6
6
  auto_summarize: true
@@ -90,3 +90,16 @@ web_fetch(url: "<URL>", max_length: 5000)
90
90
  - If the fetched page doesn't answer the question, try the next most relevant URL (max 2 fetches)
91
91
  - If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
92
92
  - Keep answers concise — extract what's relevant, don't paste the whole page
93
+
94
+ ## Restarting the server
95
+
96
+ If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
97
+
98
+ **Do NOT fetch any docs.** Just return this answer directly:
99
+
100
+ > To restart the server gracefully (hot restart, zero downtime):
101
+ > ```
102
+ > kill -USR1 $CLACKY_MASTER_PID
103
+ > ```
104
+ > This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
105
+ > The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
@@ -260,14 +260,17 @@ module Clacky
260
260
  # Extract and materialize file attachments from an inbound item_list.
261
261
  #
262
262
  # Images are downloaded from CDN and converted to data_url so the agent's
263
- # vision pipeline (partition_files → resolve_vision_images) picks them up
264
- # correctly. Other file types are returned with cdn_media metadata only
265
- # (download-on-demand is not yet implemented for non-image types).
263
+ # vision pipeline (partition_files → resolve_vision_images) picks them up.
264
+ # Files (PDF, DOCX, etc.) are downloaded to clacky-uploads so the agent's
265
+ # file processing pipeline (process_path) can parse them.
266
+ # Voice/video are kept as cdn_media metadata only (no local download).
266
267
  #
267
- # Returns Array of Hashes. Image entries include:
268
+ # Returns Array of Hashes. Image entries:
268
269
  # { type: :image, name: String, mime_type: String, data_url: String }
269
- # Other entries include:
270
- # { type: :file/:voice/:video, name: String, cdn_media: Hash }
270
+ # File entries (downloaded):
271
+ # { type: :file, name: String, path: String }
272
+ # Voice/video entries:
273
+ # { type: :voice/:video, name: String, cdn_media: Hash }
271
274
  def extract_files(item_list)
272
275
  files = []
273
276
  item_list.each do |item|
@@ -311,16 +314,46 @@ module Clacky
311
314
  name: "voice.amr",
312
315
  cdn_media: v["media"]
313
316
  }
314
- when 4 # FILE
317
+ when 4 # FILE — download to disk so agent can parse it
315
318
  fi = item["file_item"]
316
319
  next unless fi
317
- files << {
318
- type: :file,
319
- name: fi["file_name"],
320
- md5: fi["md5"],
321
- len: fi["len"],
322
- cdn_media: fi["media"]
323
- }
320
+ cdn_media = fi["media"]
321
+ file_name = fi["file_name"].to_s
322
+ file_name = "attachment" if file_name.empty?
323
+ file_md5 = fi["md5"].to_s
324
+ file_len = fi["len"].to_s
325
+
326
+ if cdn_media
327
+ begin
328
+ raw_bytes = @api_client.download_media(cdn_media, ApiClient::MEDIA_TYPE_FILE)
329
+ saved = Clacky::Utils::FileProcessor.save(body: raw_bytes, filename: file_name)
330
+ Clacky::Logger.info("[WeixinAdapter] file downloaded to #{saved[:path]} (#{raw_bytes.bytesize} bytes)")
331
+ files << {
332
+ type: :file,
333
+ name: saved[:name],
334
+ path: saved[:path],
335
+ md5: file_md5.empty? ? nil : file_md5,
336
+ len: file_len.empty? ? nil : file_len
337
+ }
338
+ rescue => e
339
+ Clacky::Logger.warn("[WeixinAdapter] Failed to download file #{file_name}: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
340
+ # Fall back to metadata-only so the agent at least knows a file was attached
341
+ files << {
342
+ type: :file,
343
+ name: file_name,
344
+ cdn_media: cdn_media,
345
+ md5: file_md5.empty? ? nil : file_md5,
346
+ len: file_len.empty? ? nil : file_len
347
+ }
348
+ end
349
+ else
350
+ files << {
351
+ type: :file,
352
+ name: file_name,
353
+ md5: file_md5.empty? ? nil : file_md5,
354
+ len: file_len.empty? ? nil : file_len
355
+ }
356
+ end
324
357
  when 5 # VIDEO
325
358
  vi = item["video_item"]
326
359
  next unless vi
@@ -9,6 +9,7 @@ require "tmpdir"
9
9
  require "uri"
10
10
  require "open3"
11
11
  require "securerandom"
12
+ require "timeout"
12
13
  require_relative "session_registry"
13
14
  require_relative "web_ui_controller"
14
15
  require_relative "scheduler"
@@ -317,12 +318,32 @@ module Clacky
317
318
  path = req.path
318
319
  method = req.request_method
319
320
 
320
- # WebSocket upgrade
321
+
322
+
323
+ # WebSocket upgrade — no timeout applied (long-lived connection)
321
324
  if websocket_upgrade?(req)
322
325
  handle_websocket(req, res)
323
326
  return
324
327
  end
325
328
 
329
+ # Wrap all REST handlers in a timeout so a hung handler (e.g. infinite
330
+ # recursion in chunk parsing) returns a proper 503 instead of an empty 200.
331
+ timeout_sec = 10
332
+ Timeout.timeout(timeout_sec) do
333
+ _dispatch_rest(req, res)
334
+ end
335
+ rescue Timeout::Error
336
+ Clacky::Logger.warn("[HTTP 503] #{method} #{path} timed out after #{timeout_sec}s")
337
+ json_response(res, 503, { error: "Request timed out" })
338
+ rescue => e
339
+ Clacky::Logger.warn("[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
340
+ json_response(res, 500, { error: e.message })
341
+ end
342
+
343
+ def _dispatch_rest(req, res)
344
+ path = req.path
345
+ method = req.request_method
346
+
326
347
  case [method, path]
327
348
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
328
349
  when ["POST", "/api/sessions"] then api_create_session(req, res)
@@ -395,9 +416,6 @@ module Clacky
395
416
  not_found(res)
396
417
  end
397
418
  end
398
- rescue => e
399
- $stderr.puts "[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
400
- json_response(res, 500, { error: e.message })
401
419
  end
402
420
 
403
421
  # ── REST API ──────────────────────────────────────────────────────────────
@@ -1102,6 +1120,9 @@ module Clacky
1102
1120
  fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
1103
1121
  fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
1104
1122
 
1123
+ # Record when the token was last updated so clients can detect re-login
1124
+ fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
1125
+
1105
1126
  # Validate credentials against live API before persisting.
1106
1127
  # Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
1107
1128
  klass = Clacky::Channel::Adapters.find(platform)
@@ -1177,10 +1198,10 @@ module Clacky
1177
1198
  }
1178
1199
  when :weixin
1179
1200
  {
1180
- base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
1181
- allowed_users: raw["allowed_users"] || [],
1182
- # Indicate whether a token is present (never expose the token itself)
1183
- has_token: !raw["token"].to_s.strip.empty?
1201
+ base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
1202
+ allowed_users: raw["allowed_users"] || [],
1203
+ has_token: !raw["token"].to_s.strip.empty?,
1204
+ token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
1184
1205
  }
1185
1206
  else
1186
1207
  {}
@@ -1624,6 +1645,7 @@ module Clacky
1624
1645
  # Returns a list of UI events (same format as WS events) for the frontend to render.
1625
1646
  def api_session_messages(session_id, req, res)
1626
1647
  unless @registry.ensure(session_id)
1648
+ Clacky::Logger.warn("[messages] registry.ensure failed", session_id: session_id)
1627
1649
  return json_response(res, 404, { error: "Session not found" })
1628
1650
  end
1629
1651
 
@@ -1636,6 +1658,7 @@ module Clacky
1636
1658
  @registry.with_session(session_id) { |s| agent = s[:agent] }
1637
1659
 
1638
1660
  unless agent
1661
+ Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
1639
1662
  return json_response(res, 200, { events: [], has_more: false })
1640
1663
  end
1641
1664
 
@@ -1691,7 +1714,7 @@ module Clacky
1691
1714
  handshake = WebSocket::Handshake::Server.new
1692
1715
  handshake << build_handshake_request(req)
1693
1716
  unless handshake.finished? && handshake.valid?
1694
- $stderr.puts "WebSocket handshake invalid"
1717
+ Clacky::Logger.warn("WebSocket handshake invalid")
1695
1718
  return
1696
1719
  end
1697
1720
 
@@ -1730,8 +1753,8 @@ module Clacky
1730
1753
  end
1731
1754
  end
1732
1755
  end
1733
- rescue IOError, Errno::ECONNRESET, Errno::EPIPE
1734
- # Client disconnected
1756
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
1757
+ # Client disconnected or socket became invalid
1735
1758
  ensure
1736
1759
  on_ws_close(conn)
1737
1760
  socket.close rescue nil
@@ -1741,7 +1764,7 @@ module Clacky
1741
1764
  res.instance_variable_set(:@header, {})
1742
1765
  res.status = -1
1743
1766
  rescue => e
1744
- $stderr.puts "WebSocket handler error: #{e.class}: #{e.message}"
1767
+ Clacky::Logger.error("WebSocket handler error: #{e.class}: #{e.message}")
1745
1768
  end
1746
1769
 
1747
1770
  # Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
@@ -1946,15 +1969,28 @@ module Clacky
1946
1969
  end
1947
1970
 
1948
1971
  # Broadcast an event to all clients subscribed to a session.
1972
+ # Dead connections (broken pipe / closed socket) are removed automatically.
1949
1973
  def broadcast(session_id, event)
1950
1974
  clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
1951
- clients.each { |conn| conn.send_json(event) rescue nil }
1975
+ dead = clients.reject { |conn| conn.send_json(event) }
1976
+ return if dead.empty?
1977
+
1978
+ @ws_mutex.synchronize do
1979
+ (@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
1980
+ end
1952
1981
  end
1953
1982
 
1954
1983
  # Broadcast an event to every connected client (regardless of session subscription).
1984
+ # Dead connections are removed automatically.
1955
1985
  def broadcast_all(event)
1956
1986
  clients = @ws_mutex.synchronize { @all_ws_conns.dup }
1957
- clients.each { |conn| conn.send_json(event) rescue nil }
1987
+ dead = clients.reject { |conn| conn.send_json(event) }
1988
+ return if dead.empty?
1989
+
1990
+ @ws_mutex.synchronize do
1991
+ @all_ws_conns.reject! { |conn| dead.include?(conn) }
1992
+ @ws_clients.each_value { |list| list.reject! { |conn| dead.include?(conn) } }
1993
+ end
1958
1994
  end
1959
1995
 
1960
1996
  # Broadcast a session_update event to all clients so they can patch their
@@ -2163,16 +2199,29 @@ module Clacky
2163
2199
  @socket = socket
2164
2200
  @version = version
2165
2201
  @send_mutex = Mutex.new
2202
+ @closed = false
2166
2203
  end
2167
2204
 
2205
+ # Returns true if the underlying socket has been detected as dead.
2206
+ def closed?
2207
+ @closed
2208
+ end
2209
+
2210
+ # Send a JSON-serializable object over the WebSocket.
2211
+ # Returns true on success, false if the connection is dead.
2168
2212
  def send_json(data)
2169
2213
  send_raw(:text, JSON.generate(data))
2170
2214
  rescue => e
2171
- $stderr.puts "WS send error: #{e.message}"
2215
+ Clacky::Logger.debug("WS send error (connection dead): #{e.message}")
2216
+ false
2172
2217
  end
2173
2218
 
2219
+ # Send a raw WebSocket frame.
2220
+ # Returns true on success, false on broken/closed socket.
2174
2221
  def send_raw(type, data)
2175
2222
  @send_mutex.synchronize do
2223
+ return false if @closed
2224
+
2176
2225
  outgoing = WebSocket::Frame::Outgoing::Server.new(
2177
2226
  version: @version,
2178
2227
  data: data,
@@ -2180,8 +2229,15 @@ module Clacky
2180
2229
  )
2181
2230
  @socket.write(outgoing.to_s)
2182
2231
  end
2232
+ true
2233
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
2234
+ @closed = true
2235
+ Clacky::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
2236
+ false
2183
2237
  rescue => e
2184
- $stderr.puts "WS send_raw error: #{e.message}"
2238
+ @closed = true
2239
+ Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
2240
+ false
2185
2241
  end
2186
2242
  end
2187
2243
  end
@@ -58,6 +58,7 @@ module Clacky
58
58
  Signal.trap("USR1") { @restart_requested = true }
59
59
  Signal.trap("TERM") { @shutdown_requested = true }
60
60
  Signal.trap("INT") { @shutdown_requested = true }
61
+ Signal.trap("HUP") { @shutdown_requested = true }
61
62
 
62
63
  # 4. Spawn first worker
63
64
  @worker_pid = spawn_worker
@@ -64,15 +64,17 @@ module Clacky
64
64
  session_data = nil
65
65
 
66
66
  @mutex.synchronize do
67
- # Already fully ready fast path.
68
- return true if @sessions.key?(session_id)
69
-
70
- # Another thread is currently restoring this session — wait for it.
67
+ # Another thread is currently restoring this session (including the case where
68
+ # @registry.create was already called but with_session agent-set is not yet done)
69
+ # wait for it to finish so callers never see agent=nil.
71
70
  if @restoring[session_id]
72
71
  @restore_cond.wait(@mutex) until !@restoring[session_id]
73
72
  return @sessions.key?(session_id)
74
73
  end
75
74
 
75
+ # Already fully ready (not being restored) — fast path.
76
+ return true if @sessions.key?(session_id)
77
+
76
78
  return false unless @session_manager && @session_restorer
77
79
 
78
80
  session_data = @session_manager.load(session_id)
@@ -174,10 +176,13 @@ module Clacky
174
176
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
175
177
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
176
178
 
177
- # ── name search ──────────────────────────────────────────────────────
179
+ # ── name / id search ─────────────────────────────────────────────────
178
180
  if q && !q.empty?
179
181
  q_down = q.downcase
180
- all = all.select { |s| (s[:name] || "").downcase.include?(q_down) }
182
+ all = all.select { |s|
183
+ (s[:name] || "").downcase.include?(q_down) ||
184
+ (s[:session_id] || "").downcase.include?(q_down)
185
+ }
181
186
  end
182
187
 
183
188
  all = all.select { |s| (s[:created_at] || "") < before } if before
@@ -208,6 +208,14 @@ module Clacky
208
208
  forward_to_subscribers { |sub| sub.show_progress(message) }
209
209
  end
210
210
 
211
+ # Stream shell stdout/stderr lines to the browser while a command is running.
212
+ # Called periodically from the progress_timer thread in agent.rb.
213
+ def show_tool_stdout(lines)
214
+ return if lines.nil? || lines.empty?
215
+ emit("tool_stdout", lines: lines)
216
+ # Not forwarded to IM subscribers — too noisy
217
+ end
218
+
211
219
  def clear_progress
212
220
  elapsed = @progress_start_time ? (Time.now - @progress_start_time).round(1) : 0
213
221
  @progress_start_time = nil
@@ -341,6 +341,10 @@ module Clacky
341
341
  @safe_check_command = Clacky::Utils::Encoding.safe_check(command)
342
342
 
343
343
  case @safe_check_command
344
+ when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i
345
+ raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
346
+ when /clacky\s+server/
347
+ raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
344
348
  when /^rm\s+/
345
349
  replace_rm_command(command)
346
350
  when /^chmod\s+x/