agent-lock 0.2.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +1 -0
  3. data/.envrc +2 -0
  4. data/.rubocop_todo.yml +90 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +54 -0
  7. data/CHANGELOG.md +48 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +374 -0
  10. data/Rakefile +12 -0
  11. data/exe/agent-lock +8 -0
  12. data/exe/alock +9 -0
  13. data/justfile +127 -0
  14. data/lib/agent/lock/cli/commands/acquire.rb +87 -0
  15. data/lib/agent/lock/cli/commands/base.rb +148 -0
  16. data/lib/agent/lock/cli/commands/break.rb +36 -0
  17. data/lib/agent/lock/cli/commands/check.rb +56 -0
  18. data/lib/agent/lock/cli/commands/completion.rb +50 -0
  19. data/lib/agent/lock/cli/commands/list.rb +70 -0
  20. data/lib/agent/lock/cli/commands/mine.rb +35 -0
  21. data/lib/agent/lock/cli/commands/note.rb +37 -0
  22. data/lib/agent/lock/cli/commands/release.rb +33 -0
  23. data/lib/agent/lock/cli/commands/release_all.rb +24 -0
  24. data/lib/agent/lock/cli/commands/resume.rb +36 -0
  25. data/lib/agent/lock/cli/commands/skill.rb +75 -0
  26. data/lib/agent/lock/cli/commands/version.rb +20 -0
  27. data/lib/agent/lock/cli/commands/whoami.rb +83 -0
  28. data/lib/agent/lock/cli.rb +80 -0
  29. data/lib/agent/lock/error.rb +10 -0
  30. data/lib/agent/lock/freeze.rb +109 -0
  31. data/lib/agent/lock/identity.rb +166 -0
  32. data/lib/agent/lock/launcher.rb +126 -0
  33. data/lib/agent/lock/manager.rb +313 -0
  34. data/lib/agent/lock/process_info.rb +60 -0
  35. data/lib/agent/lock/record.rb +216 -0
  36. data/lib/agent/lock/scope.rb +173 -0
  37. data/lib/agent/lock/skill.rb +104 -0
  38. data/lib/agent/lock/store/file_system_store.rb +161 -0
  39. data/lib/agent/lock/store/redis_store.rb +225 -0
  40. data/lib/agent/lock/store.rb +104 -0
  41. data/lib/agent/lock/tree.rb +126 -0
  42. data/lib/agent/lock/version.rb +7 -0
  43. data/lib/agent/lock.rb +30 -0
  44. data/sig/agent/lock.rbs +6 -0
  45. data/skills/agent-lock/SKILL.md +59 -0
  46. metadata +150 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Agent
6
+ module Lock
7
+ module CLI
8
+ module Commands
9
+ # What a session should run when it finishes, so the next one does not
10
+ # have to work out whether it crashed.
11
+ class ReleaseAll < Base
12
+ desc "Release every lock this session holds"
13
+
14
+ def call(**options)
15
+ result = manager(options[:dir]).release_all
16
+ result.records.each { |record| say("RELEASED #{record.scope}") }
17
+ say("Released #{result.records.size} lock(s).")
18
+ finish(result)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Agent
6
+ module Lock
7
+ module CLI
8
+ module Commands
9
+ # Pick up work a reboot or a crash interrupted.
10
+ #
11
+ # A lock whose holder died is orphaned rather than deleted when it has
12
+ # notes in it. This takes it back, prints what the last session wrote,
13
+ # and makes it a live claim again under the current identity.
14
+ class Resume < Base
15
+ desc "Take back a lock a crash or a reboot interrupted"
16
+
17
+ argument :scope, required: true, desc: "The path or glob to pick up"
18
+
19
+ def call(scope:, **options)
20
+ result = manager(options[:dir]).resume(scope)
21
+
22
+ if result.status == :not_found
23
+ say("NOTHING TO RESUME #{scope}")
24
+ else
25
+ say("RESUMED #{result.record.scope} (holder: #{result.record.agent_id})")
26
+ say("")
27
+ say(result.record.intent)
28
+ end
29
+
30
+ finish(result)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../../skill"
5
+
6
+ module Agent
7
+ module Lock
8
+ module CLI
9
+ module Commands
10
+ # Where the bundled skill is, for a harness that would rather link it
11
+ # than copy it, or a configuration that names it by path.
12
+ class SkillPath < Base
13
+ desc "Print the directory of the skill this gem ships"
14
+
15
+ def call(**)
16
+ say(Skill.source)
17
+ end
18
+ end
19
+
20
+ # Copy the bundled skill into a skills directory, so an agent learns
21
+ # how to claim files before it first needs to.
22
+ class SkillInstall < Base
23
+ desc "Copy the skill this gem ships into a skills directory"
24
+
25
+ option :into, type: :string, default: nil, aliases: ["-o"],
26
+ desc: "The skills directory, default ~/.agents/skills"
27
+ option :for, type: :string, default: nil, aliases: ["-a"],
28
+ desc: "AI coding agent name, eg 'codex', or 'claude'"
29
+ option :force, type: :boolean, default: false, aliases: ["-f"],
30
+ desc: "Replace a copy that differs from this one"
31
+
32
+ example ["", "--into ~/.claude/skills", "--force"]
33
+ example ["", "--for claude"]
34
+
35
+ # @param options [Hash]
36
+ def call(**options)
37
+ into = destination_for(options)
38
+ skill = into ? Skill.new(into: into) : Skill.new
39
+
40
+ result = skill.install(force: options[:force])
41
+ report(result)
42
+ finish(result)
43
+ end
44
+
45
+ private
46
+
47
+ # @param options [Hash]
48
+ # @return [String, nil] where to install, or nil for Skill's own default
49
+ def destination_for(options)
50
+ return options[:into] unless options[:for]
51
+
52
+ warn_("Both --for and --into options are provided; --for will be ignored") if options[:into]
53
+ return options[:into] if options[:into]
54
+
55
+ agent_dir = options[:for] == "claude" ? ".claude" : ".agents"
56
+ File.join(Dir.home, agent_dir, "skills")
57
+ end
58
+
59
+ # @param result [Skill::Result]
60
+ def report(result)
61
+ case result.status
62
+ when :installed then say("INSTALLED #{result.path}")
63
+ when :current then say("UP TO DATE #{result.path}")
64
+ when :differs
65
+ warn_("REFUSED: #{result.path} differs from the copy this gem ships")
66
+ warn_(" #{program} skill install --force # replace it")
67
+ when :linked
68
+ warn_("REFUSED: #{result.path} is a symlink, so something else installs it; left alone")
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Agent
6
+ module Lock
7
+ module CLI
8
+ module Commands
9
+ # What is installed, for a bug report.
10
+ class Version < Base
11
+ desc "Print the version"
12
+
13
+ def call(**)
14
+ say(Agent::Lock::VERSION)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../../identity"
5
+
6
+ module Agent
7
+ module Lock
8
+ module CLI
9
+ module Commands
10
+ # Who a lock taken from here would say holds it, and who its parent is.
11
+ #
12
+ # A sub-agent runs inside its parent's process, so nothing but the
13
+ # AGENT_ID it was told to use tells the two apart. A typo or a missing
14
+ # export puts its locks down under somebody else's name, where they
15
+ # block no sibling, and nothing says so. This is the check to run
16
+ # before the first claim: `AGENT_ID=rey-frontend alock whoami`.
17
+ #
18
+ # It reads the environment and nothing else. No tree and no store, so
19
+ # asking the question can neither fail outside a checkout nor leave a
20
+ # store behind in one.
21
+ class Whoami < Base
22
+ # Where #id came from, worded for a human. The keys are Identity's
23
+ # own symbols, which are what --json reports.
24
+ SOURCES = {
25
+ explicit: "from AGENT_ID",
26
+ session: "from CLAUDE_SESSION_ID",
27
+ fingerprint: "from the process fingerprint"
28
+ }.freeze
29
+
30
+ # Where #parent_id came from, likewise.
31
+ PARENT_SOURCES = {
32
+ explicit: "from AGENT_PARENT_ID",
33
+ inferred: "inferred: the session this runs in, since AGENT_ID names somebody else"
34
+ }.freeze
35
+
36
+ desc "Say who a lock taken now would be held by, and where that came from"
37
+
38
+ option :json, type: :boolean, default: false, desc: "Machine-readable output"
39
+
40
+ example ["", "--json"]
41
+
42
+ # @param options [Hash]
43
+ def call(**options)
44
+ identity = Identity.current
45
+ warn_shared(identity) unless identity.source == :explicit
46
+ return say(JSON.pretty_generate(as_hash(identity))) if options[:json]
47
+
48
+ say("id: #{identity.id} (#{SOURCES.fetch(identity.source)})")
49
+ say(parent_line(identity))
50
+ end
51
+
52
+ private
53
+
54
+ # @param identity [Identity]
55
+ # @return [String]
56
+ def parent_line(identity)
57
+ return "parent: none" if identity.parent_id.nil?
58
+
59
+ "parent: #{identity.parent_id} (#{PARENT_SOURCES.fetch(identity.parent_source)})"
60
+ end
61
+
62
+ # The failure this command exists to catch. Without AGENT_ID, every
63
+ # sub-agent of this session inherits the same environment and runs
64
+ # in the same process, so each resolves to this same id and none of
65
+ # them can ever block another.
66
+ #
67
+ # @param identity [Identity]
68
+ def warn_shared(identity)
69
+ warn_("note: every sub-agent of this session answers to #{identity.id} too, so none can block another.")
70
+ warn_(" Give each one its own: AGENT_ID=<name> #{program} acquire ...")
71
+ end
72
+
73
+ # @param identity [Identity]
74
+ # @return [Hash]
75
+ def as_hash(identity)
76
+ { id: identity.id, source: identity.source,
77
+ parent_id: identity.parent_id, parent_source: identity.parent_source }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "manager"
4
+ require "dry/cli"
5
+
6
+ module Agent
7
+ module Lock
8
+ # The command line, and nothing else. Every command here is a thin shell:
9
+ # parse flags, call one Manager method, print the result, set an exit code.
10
+ # No command decides anything a library object could decide instead.
11
+ module CLI
12
+ # What the gem is installed as. Taken from the gemspec's one executable
13
+ # rather than from $PROGRAM_NAME, which under a test runner is the
14
+ # runner. The completion script it emits names the program in every
15
+ # line, so guessing it wrong is not a cosmetic mistake.
16
+ PROGRAM_NAME = "alock"
17
+
18
+ # A registry whose commands are already bound to this launcher, so a
19
+ # command writes to the streams it was given rather than to the process's.
20
+ #
21
+ # @param launcher [Launcher]
22
+ # @return [Dry::CLI::Registry]
23
+ def self.registry_for(launcher)
24
+ # `extend` and `register` have to be sent to the new class explicitly.
25
+ # Inside a `tap` block `self` is still this module, so writing them
26
+ # bare turned Agent::Lock::CLI itself into the registry, handed back a
27
+ # class that knew no commands, and left the completion command bound
28
+ # to that empty class.
29
+ registry = Class.new { extend Dry::CLI::Registry }
30
+
31
+ COMMANDS.each do |name, (klass, aliases)|
32
+ registry.register(name, klass.new(launcher), aliases: aliases)
33
+ end
34
+
35
+ registry.register("completion", Commands::Completion[registry, program_name: PROGRAM_NAME])
36
+ registry
37
+ end
38
+
39
+ # The whole command line, in one place, so adding a verb is one line
40
+ # rather than three. Filled in below, once the commands are loaded.
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "cli/commands/base"
46
+ require_relative "cli/commands/acquire"
47
+ require_relative "cli/commands/release"
48
+ require_relative "cli/commands/check"
49
+ require_relative "cli/commands/list"
50
+ require_relative "cli/commands/mine"
51
+ require_relative "cli/commands/release_all"
52
+ require_relative "cli/commands/break"
53
+ require_relative "cli/commands/note"
54
+ require_relative "cli/commands/resume"
55
+ require_relative "cli/commands/whoami"
56
+ require_relative "cli/commands/skill"
57
+ require_relative "cli/commands/version"
58
+ require_relative "cli/commands/completion"
59
+
60
+ module Agent
61
+ module Lock
62
+ module CLI
63
+ COMMANDS = {
64
+ "acquire" => [Commands::Acquire, []],
65
+ "release" => [Commands::Release, []],
66
+ "check" => [Commands::Check, []],
67
+ "list" => [Commands::List, ["ls"]],
68
+ "mine" => [Commands::Mine, []],
69
+ "release-all" => [Commands::ReleaseAll, []],
70
+ "break" => [Commands::Break, []],
71
+ "note" => [Commands::Note, []],
72
+ "resume" => [Commands::Resume, []],
73
+ "whoami" => [Commands::Whoami, []],
74
+ "skill path" => [Commands::SkillPath, []],
75
+ "skill install" => [Commands::SkillInstall, []],
76
+ "version" => [Commands::Version, %w[-v --version]]
77
+ }.freeze
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Lock
5
+ # The base of everything this gem raises. In a file of its own so that any
6
+ # part of the library can require it without reaching for the entry point,
7
+ # which would require the part doing the asking.
8
+ class Error < StandardError; end
9
+ end
10
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Agent
6
+ module Lock
7
+ # Optional teeth, for the scope you want nobody to touch at all.
8
+ #
9
+ # An advisory lock works because every agent checks it. That covers the
10
+ # agents that check. `chflags uchg` on macOS makes the file unwritable by
11
+ # anything, checking or not, which is the right answer for a handful of
12
+ # files that must not move while something else runs.
13
+ #
14
+ # It is opt-in for three reasons. It is macOS only, since Linux's `chattr
15
+ # +i` needs root. It denies the holder too, so it fits a freeze rather than
16
+ # a file you are editing. And a session that dies with files frozen leaves
17
+ # a tree where `git checkout` and `rm -rf` fail with "Operation not
18
+ # permitted", which is why every frozen path is written into the lock: the
19
+ # thaw does not depend on the process that froze them still being alive.
20
+ module Freeze
21
+ # A freeze walks and flags every matched file, so a scope covering a
22
+ # whole checkout is a mistake rather than an instruction.
23
+ LIMIT = 500
24
+
25
+ # How many paths one `chflags` invocation is given.
26
+ BATCH = 200
27
+
28
+ class TooBroad < Error; end
29
+
30
+ module_function
31
+
32
+ # @return [Boolean]
33
+ def supported? = RUBY_PLATFORM.include?("darwin")
34
+
35
+ # @param scope [Scope]
36
+ # @param tree [Tree]
37
+ # @return [Array<String>] the files it would freeze, relative to the root
38
+ def matches(scope, tree)
39
+ Dir.glob(recursive(scope.pattern), base: tree.root, flags: File::FNM_EXTGLOB)
40
+ .select { |rel| File.file?(File.join(tree.root, rel)) }
41
+ .reject { |rel| rel.start_with?(".git/") }
42
+ .sort
43
+ end
44
+
45
+ # A trailing `**` means "everything under here" to this gem, and to
46
+ # anybody typing it. It does not mean that to Dir.glob, where a bare
47
+ # `**` at the end matches one level, exactly like `*`. Only `**/` walks
48
+ # down. So a scope that covers a subtree is spelled out before globbing.
49
+ #
50
+ # @param pattern [String]
51
+ # @return [String]
52
+ def recursive(pattern) = pattern.end_with?("**") ? "#{pattern}/*" : pattern
53
+
54
+ # @param paths [Array<String>] relative to the tree root
55
+ # @param tree [Tree]
56
+ # @param force [Boolean] allow a freeze wider than LIMIT
57
+ # @return [Array<String>] what was frozen
58
+ def apply(paths, tree:, force: false)
59
+ raise TooBroad, "--enforce needs macOS; this is #{RUBY_PLATFORM}" unless supported?
60
+ return [] if paths.empty?
61
+ raise TooBroad, "#{paths.size} files is wider than a freeze should be" if paths.size > LIMIT && !force
62
+
63
+ chflags("uchg", paths, tree)
64
+ end
65
+
66
+ # Best effort on purpose: a path that has since been deleted or already
67
+ # thawed is not a reason to leave the rest frozen.
68
+ #
69
+ # @return [Array<String>] what was thawed
70
+ def clear(paths, tree:)
71
+ return [] if paths.nil? || paths.empty?
72
+
73
+ chflags("nouchg", paths, tree)
74
+ end
75
+
76
+ # Only the files `chflags` actually accepted come back.
77
+ #
78
+ # A batch that fails is retried one file at a time, because the usual
79
+ # reason is a single path somebody else owns, and reporting the whole
80
+ # batch as frozen would leave the lock claiming protection it does not
81
+ # have. Reporting the whole batch as failed would be just as wrong.
82
+ #
83
+ # @return [Array<String>] relative paths that are now flagged
84
+ def chflags(flag, paths, tree)
85
+ return [] unless supported?
86
+
87
+ paths.each_slice(BATCH)
88
+ .flat_map { |batch| flag_batch(flag, existing(batch, tree)) }
89
+ .map { |path| path.delete_prefix("#{tree.root}/") }
90
+ end
91
+
92
+ # @return [Array<String>] absolute paths that are still there to flag
93
+ def existing(batch, tree)
94
+ batch.map { |rel| File.join(tree.root, rel) }.select { |path| File.exist?(path) }
95
+ end
96
+
97
+ # @return [Array<String>] the ones the command accepted
98
+ def flag_batch(flag, absolute)
99
+ return [] if absolute.empty?
100
+ return absolute if run(flag, absolute)
101
+
102
+ absolute.select { |path| run(flag, [path]) }
103
+ end
104
+
105
+ # @return [Boolean] whether the command reported success
106
+ def run(flag, paths) = system("chflags", flag, *paths, out: File::NULL, err: File::NULL) || false
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "socket"
5
+ require "digest"
6
+
7
+ module Agent
8
+ module Lock
9
+ # Who is asking, and whether they are still around.
10
+ #
11
+ # A lock is worthless unless the holder gives the same answer every time it
12
+ # is asked. The obvious answer, this process's own pid, is the wrong one: an
13
+ # agent harness runs each command in a shell of its own, so a lock acquired
14
+ # by one invocation could never be released by the next. The identity has to
15
+ # belong to the session, not to the process that happens to be speaking for
16
+ # it right now.
17
+ #
18
+ # In order of preference:
19
+ #
20
+ # AGENT_ID what a human or a harness set on purpose
21
+ # CLAUDE_SESSION_ID the session, which survives --resume
22
+ # fingerprint the first ancestor process that is not a shell
23
+ #
24
+ # The fingerprint is the fallback that needs explaining. Walking up from
25
+ # this process, the shells are throwaway and the thing above them is not:
26
+ # the `claude` or `codex` process driving the session, or the terminal a
27
+ # human is typing in. Its pid and start time, hashed, are stable for as
28
+ # long as that session lives and different for anybody else's.
29
+ class Identity
30
+ SHELLS = %w[sh bash zsh ksh csh tcsh fish dash].freeze
31
+ MAX_HOPS = 10
32
+
33
+ class << self
34
+ # Deliberately not memoized. A forked CLI computes this once and dies,
35
+ # but the same code runs inside a long-lived process during the specs
36
+ # and inside any harness that loads the library, where a cached answer
37
+ # would outlive the environment it was computed from.
38
+ #
39
+ # @return [Identity]
40
+ def current = new
41
+
42
+ # The ancestry walk, on the other hand, cannot change while this
43
+ # process lives, and it costs two `ps` calls per hop.
44
+ #
45
+ # @return [Integer]
46
+ def session_pid = @session_pid ||= yield
47
+ end
48
+
49
+ # @param env [Hash]
50
+ def initialize(env: ENV)
51
+ @env = env
52
+ end
53
+
54
+ # @return [String] the holder name written into a lock
55
+ def id
56
+ @id ||= explicit || session || fingerprint
57
+ end
58
+
59
+ # The session that spawned this one: AGENT_PARENT_ID when a harness says
60
+ # so, and otherwise inferred.
61
+ #
62
+ # The inference exists because Claude Code runs a sub-agent inside its
63
+ # parent's own process and sets nothing to tell them apart, so without it
64
+ # every sub-agent resolved to its parent's fingerprint and none of them
65
+ # could ever block another. A sub-agent's one distinguishing mark is the
66
+ # AGENT_ID it was told to use. When that differs from what this session
67
+ # would answer to without it, the session is, by elimination, the
68
+ # parent.
69
+ #
70
+ # @return [String, nil]
71
+ def parent_id
72
+ return @parent_id if defined?(@parent_id)
73
+
74
+ @parent_id = declared_parent || inferred_parent
75
+ end
76
+
77
+ # Where #id came from, so a session can check what it is being taken for
78
+ # before a lock is written under the wrong name.
79
+ #
80
+ # @return [Symbol] :explicit, :session or :fingerprint
81
+ def source
82
+ return :explicit if explicit
83
+ return :session if session
84
+
85
+ :fingerprint
86
+ end
87
+
88
+ # Where #parent_id came from. An inferred parent is a guess a human may
89
+ # want to overrule with AGENT_PARENT_ID, so it is reported as one.
90
+ #
91
+ # @return [Symbol, nil] :explicit, :inferred, or nil when there is no parent
92
+ def parent_source
93
+ return :explicit if declared_parent
94
+
95
+ :inferred if parent_id
96
+ end
97
+
98
+ # Evidence, not identity: enough to ask later whether the holder is still
99
+ # running. The pid alone is not enough, since pids are reused, and the
100
+ # host matters because a lock store can be on a shared or synced volume.
101
+ #
102
+ # @return [Hash{Symbol => Object}]
103
+ def evidence
104
+ { pid: session_pid, started: ProcessInfo.started_at(session_pid), host: Socket.gethostname }
105
+ end
106
+
107
+ private
108
+
109
+ def explicit = presence(@env["AGENT_ID"])
110
+
111
+ def declared_parent = presence(@env["AGENT_PARENT_ID"])
112
+
113
+ # What this session answers to when nobody names it, which is the
114
+ # orchestrator's id. The session id counts as well as the fingerprint:
115
+ # under a harness that exports CLAUDE_SESSION_ID the orchestrator's locks
116
+ # carry that, and a parent inferred from the fingerprint alone would
117
+ # match none of them. A session that named itself by its own fingerprint
118
+ # is not its own child.
119
+ #
120
+ # @return [String, nil]
121
+ def inferred_parent
122
+ return nil unless explicit
123
+
124
+ own = session || fingerprint
125
+ own unless own == explicit
126
+ end
127
+
128
+ def session
129
+ value = presence(@env["CLAUDE_SESSION_ID"])
130
+ value && "session-#{value[0, 8]}"
131
+ end
132
+
133
+ # @return [String] e.g. "claude-1f4c8a02"
134
+ def fingerprint
135
+ pid = session_pid
136
+ name = ProcessInfo.command(pid) || "agent"
137
+ digest = Digest::SHA256.hexdigest(
138
+ [pid, ProcessInfo.started_at(pid), Etc.getpwuid(::Process.uid)&.name, Socket.gethostname].join("|")
139
+ )
140
+ "#{name}-#{digest[0, 8]}"
141
+ end
142
+
143
+ # The first ancestor that is not a shell, which is the process that lasts
144
+ # as long as the session does.
145
+ #
146
+ # @return [Integer]
147
+ def session_pid
148
+ self.class.session_pid do
149
+ pid = ::Process.ppid
150
+ MAX_HOPS.times do
151
+ name = ProcessInfo.command(pid)
152
+ break unless name && SHELLS.include?(name.delete_prefix("-"))
153
+
154
+ parent = ProcessInfo.parent_of(pid)
155
+ break unless parent && parent > 1
156
+
157
+ pid = parent
158
+ end
159
+ pid
160
+ end
161
+ end
162
+
163
+ def presence(value) = value.nil? || value.strip.empty? ? nil : value.strip
164
+ end
165
+ end
166
+ end