@zalom/plastic 1.14.0 → 1.14.1

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.
@@ -1,5 +1,5 @@
1
1
  #!/bin/bash
2
- # plastic-hook-version: 1.1.0
2
+ # plastic-hook-version: 1.2.0
3
3
  # Runs on SessionStart — checks npm for a newer version on the installed
4
4
  # channel, writes cache. Channel-aware: queries dist-tags and picks the best
5
5
  # upgrade target instead of blindly trusting the `latest` tag.
@@ -23,7 +23,16 @@ if [ -z "$CURRENT" ]; then
23
23
  exit 0
24
24
  fi
25
25
 
26
- # Background check — don't block session start
26
+ # Background check — don't block session start.
27
+ #
28
+ # The three redirects are load-bearing (intent 289). Without them this subshell inherits
29
+ # the caller's stdout, stderr, and stdin. A caller that reads those pipes to EOF then
30
+ # blocks for as long as the npm call takes, not for as long as this script takes:
31
+ # scripts/codex-hook drives this launcher through Open3.capture3 under a 5 s timeout, and
32
+ # a slow network made that timeout fire, close the pipes, and print Open3 reader-thread
33
+ # backtraces into a hook's output channel. The body writes only to $CACHE_FILE, so
34
+ # nothing is lost by sending its descriptors to /dev/null; what changes is that the
35
+ # inherited pipes close at fork, so the last write end disappears when THIS script exits.
27
36
  (
28
37
  TAGS=$(npm view @zalom/plastic dist-tags --json 2>/dev/null)
29
38
 
@@ -41,6 +50,6 @@ fi
41
50
  printf '{"current":"%s","latest":"%s","checked":"%s","updateAvailable":false}\n' \
42
51
  "$CURRENT" "$CURRENT" "$CHECKED" > "$CACHE_FILE"
43
52
  fi
44
- ) &
53
+ ) >/dev/null 2>&1 </dev/null &
45
54
 
46
55
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,11 +33,13 @@
33
33
  # unmodified, the identical "drive the body, relay its output" pattern used for
34
34
  # the live-state hooks. The one adaptation shared by both the live-state and
35
35
  # shell-tool paths is threading the payload's session_id into
36
- # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
37
- # a bounded timeout: hooks/check-update backgrounds a real npm network call
38
- # without redirecting its output away from the inherited stdout/stderr pipes,
39
- # and Codex invokes hooks synchronously, so without a bound a slow network call
40
- # could hold this dispatcher's pipe open past the launcher's own (fast) exit.
36
+ # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it,
37
+ # plus a bounded timeout: Codex invokes hooks synchronously, so a launcher
38
+ # that never releases this dispatcher's pipes would hang the session.
39
+ # hooks/check-update used to leak them by backgrounding an npm network call
40
+ # with the inherited descriptors (fixed at the root in intent 289 by
41
+ # redirecting that subshell to /dev/null); the bound stays for any other
42
+ # launcher that misbehaves, and capture_launcher keeps the timeout path silent.
41
43
  require "json"
42
44
  require "open3"
43
45
  require "rbconfig"
@@ -54,6 +56,30 @@ STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-ar
54
56
  STATE_TIMEOUT = 5
55
57
  SHELL_HOOKS = %w[bash-gate].freeze
56
58
 
59
+ # One capture path for both relayed-launcher branches (intent 289). The 5 s bound covers a
60
+ # launcher that leaks THIS process's stdout and stderr into a longer-lived background child
61
+ # (hooks/check-update did exactly that before 289): it does not bound a launcher that simply
62
+ # never exits in the foreground, since Open3.popen3's ensure joins the child's wait thread on
63
+ # the way out regardless of Timeout. What is added here is silence on the timeout path. A
64
+ # leaked-pipe launcher leaves Open3's reader threads blocked in IO#read; when Timeout unwinds
65
+ # and popen3's ensure closes those pipes underneath them, both threads raise IOError and Ruby
66
+ # prints a backtrace to real stderr, which any caller merging our stdout and stderr then reads
67
+ # as hook output. Thread#report_on_exception is seeded from the class default AT THREAD
68
+ # CREATION and capture3 creates its readers inside this block (open3.rb:664-665), so
69
+ # clearing the default here is enough. Restored in ensure: nothing else in this process,
70
+ # including a genuinely unexpected exception later, loses its report.
71
+ def capture_launcher(env, *argv, raw:, cwd:)
72
+ previous = Thread.report_on_exception
73
+ Thread.report_on_exception = false
74
+ begin
75
+ Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env, *argv, stdin_data: raw, chdir: cwd) }
76
+ rescue StandardError
77
+ ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
78
+ ensure
79
+ Thread.report_on_exception = previous
80
+ end
81
+ end
82
+
57
83
  gate = ARGV[0].to_s
58
84
  raw = ($stdin.read rescue nil)
59
85
  exit 0 if raw.nil? || raw.strip.empty?
@@ -67,11 +93,7 @@ if STATE_HOOKS.include?(gate)
67
93
  cwd = payload["cwd"].to_s
68
94
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
69
95
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
70
- out, err, status = begin
71
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), launcher, stdin_data: raw, chdir: cwd) }
72
- rescue StandardError
73
- ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
74
- end
96
+ out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd)
75
97
  print out unless out.to_s.empty?
76
98
  warn err unless err.to_s.empty?
77
99
  exit(status ? status.exitstatus : 0)
@@ -87,11 +109,7 @@ if SHELL_HOOKS.include?(gate)
87
109
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
88
110
  script = File.join(__dir__, "hook-#{gate}")
89
111
  argv = [script]
90
- out, err, status = begin
91
- Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
92
- rescue StandardError
93
- ["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
94
- end
112
+ out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, raw: raw, cwd: cwd)
95
113
  print out unless out.to_s.empty?
96
114
  warn err unless err.to_s.empty?
97
115
  exit(status ? status.exitstatus : 0)