terret-tools-std 0.1.0

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.
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module ToolsStd
5
+ # `job_start` / `job_collect` / `job_stop` (docs/subagents.md §6) — work
6
+ # that outlives the tool call that started it, over ctx[:jobs]. snake_case
7
+ # because these three have no Claude Code equivalent to be verbatim with,
8
+ # the same rule that produced `terminal_*` in M7.
9
+ #
10
+ # The split in the metadata is the whole design in one table. Starting and
11
+ # stopping put a process into the world or take one out of it, so both are
12
+ # mutating and both are governed by policy; collecting reads a buffer and
13
+ # asks nobody. That is what lets a delegated agent watch a job it could
14
+ # never have started: a subagent's approval can never be answered
15
+ # (docs/subagents.md §2), so a `:policy` call from one is denied, while
16
+ # `job_collect` runs for anybody.
17
+ #
18
+ # Neither `cwd` nor `env` is a tool argument, for the reason the terminal
19
+ # tools give: a cwd the model chose is contained by nothing, and an env it
20
+ # writes is a place for a credential to be laundered into a child process.
21
+ # Both stay the row's decision, where a deployment can see them.
22
+ class Jobs < Hames::Service
23
+ service_key :tools_std_jobs
24
+ inject :tools, :jobs, :sandbox
25
+ config_schema max_output: { type: Integer, default: 30_000,
26
+ doc: "bytes of collected job output returned to the model before truncation" }
27
+
28
+ # The same literal Bash, WebFetch and Task separate their output with,
29
+ # and it carries the same caveats: a readability device rather than a
30
+ # security boundary — a job can print the line itself — whose actual
31
+ # delivery is that the genuine remarks are always last and always
32
+ # advisory data that nothing downstream acts on.
33
+ LEDGER = "--- terret ---"
34
+
35
+ # What one collect may show. The seam has its own cap
36
+ # (Jobs::DEFAULT_MAX_OUTPUT, a mebibyte) and that one is a memory bound;
37
+ # this is a display decision, the tool's own honest cap, exactly as
38
+ # Bash's is.
39
+ DEFAULT_MAX_OUTPUT = 30_000
40
+
41
+ START_DESCRIPTION =
42
+ "Start a shell command in the background and get back a job id. The command keeps " \
43
+ "running after this call returns and after the turn that started it ends; read what " \
44
+ "it has written with job_collect, and end it with job_stop. Each job is a fresh " \
45
+ "shell, not this session's persistent bash, so a `cd` or an `export` from a Bash " \
46
+ "call is not in effect here. A job does not survive a restart of the harness. If " \
47
+ "this call is interrupted before its result is recorded, a resume of the turn runs " \
48
+ "it again and starts a SECOND job — the first may still be running, with output " \
49
+ "nobody will collect — so after a resume, use job_collect to check for two ids " \
50
+ "where you expected one."
51
+
52
+ COLLECT_DESCRIPTION =
53
+ "Read whatever a job has written since the last time it was collected, and find out " \
54
+ "whether it is still running. The output is drained: the same bytes are never " \
55
+ "handed back twice, so keep what you are given."
56
+
57
+ STOP_DESCRIPTION =
58
+ "End a job: it is sent SIGTERM, then SIGKILL if it does not leave. Collect the job " \
59
+ "once more afterwards for anything it wrote on its way out."
60
+
61
+ def start(ctx)
62
+ @ctx = ctx
63
+ register_collect
64
+ register_stop
65
+ # job_start's approval is derived from sandbox isolation the way Bash's
66
+ # is (docs/exec.md §5, §13): job_start runs `bash -lc <cmd>` in a fresh
67
+ # shell, so unsandboxed it is arbitrary shell execution and needs a human
68
+ # every time, while inside a sandbox the container is the backstop and it
69
+ # is governed like any other mutating tool. The verdict is captured at
70
+ # registration, so a hot sandbox swap re-derives it through the
71
+ # config/updated listener rather than leaving a stale value in front of a
72
+ # shell whose isolation changed underneath it.
73
+ #
74
+ # The effect frame owns whichever registration is current: the loader
75
+ # emits config/updated OUTSIDE with_owner, so a registration made from
76
+ # the listener would belong to no row — and an ownerless job_start would
77
+ # outlive the row that mounted it, holding process-spawning authority
78
+ # nothing in the harness could still take away.
79
+ @ctx.effect do
80
+ register_start
81
+ -> { @start_registration&.call }
82
+ end
83
+ @ctx.on("config/updated") { |_id, _config| refresh_start! }
84
+ end
85
+
86
+ # max_output is read at call time, so a swapped row governs the very next
87
+ # call; every other knob these tools reach (the job cap, the cwd) belongs
88
+ # to the jobs row and is read there. job_start's approval, by contrast, IS
89
+ # a registration-time capture — the config/updated listener above, not a
90
+ # remount, is what keeps it current.
91
+ def reconfigure(_config); end
92
+
93
+ private
94
+
95
+ # `ctx:` is passed explicitly: the registry would otherwise record the
96
+ # frame on the context it was started in (the root), so a roster mounted
97
+ # into a forked agent scope would leave registrations behind that outlive
98
+ # the fork — a disposed agent with a tool of its own that can still spawn
99
+ # processes.
100
+ def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
101
+ @ctx[:tools].register(name: name, description: description, params: params,
102
+ mutating: mutating, approval: approval,
103
+ concurrency: concurrency, ctx: @ctx, &handler)
104
+ end
105
+
106
+ def object_schema(properties, required)
107
+ { type: "object", properties: properties, required: required }
108
+ end
109
+
110
+ def id_property
111
+ { type: "string", description: "The job id job_start handed back" }
112
+ end
113
+
114
+ def register_start
115
+ params = object_schema(
116
+ { command: { type: "string",
117
+ description: "The shell command line to run in the background" } },
118
+ %w[command]
119
+ )
120
+ # `command` is required in the schema and defaulted here: a model that
121
+ # omits it has made a mistake, and an omitted keyword would cost a
122
+ # whole turn to an ArgumentError where a defaulted one costs a result
123
+ # the model can read and correct.
124
+ @start_approval = derive_approval
125
+ @start_registration = tool("job_start", START_DESCRIPTION, params, mutating: true,
126
+ approval: @start_approval, concurrency: :serial) do |session_id:, command: nil|
127
+ started(@ctx[:jobs].start(command!(command), session: session_id))
128
+ end
129
+ end
130
+
131
+ # §13 / docs/exec.md §5: the sandbox's own verdict, never a config knob of
132
+ # this row's — an isolation claim belongs to the thing doing the isolating.
133
+ def derive_approval = @ctx[:sandbox].isolated? ? :policy : :always
134
+
135
+ def refresh_start!
136
+ return if derive_approval == @start_approval
137
+
138
+ @start_registration&.call
139
+ register_start
140
+ end
141
+
142
+ def register_collect
143
+ # :parallel because a collect is a read of a buffer with no ordering
144
+ # against its siblings — watching four jobs in one message is the case
145
+ # the barrier was declared for.
146
+ tool("job_collect", COLLECT_DESCRIPTION, object_schema({ id: id_property }, %w[id]),
147
+ mutating: false, approval: :never, concurrency: :parallel) do |session_id:, id: nil|
148
+ render(@ctx[:jobs].collect(id!(id, "job_collect"), session: session_id))
149
+ end
150
+ end
151
+
152
+ def register_stop
153
+ tool("job_stop", STOP_DESCRIPTION, object_schema({ id: id_property }, %w[id]),
154
+ mutating: true, approval: :policy, concurrency: :serial) do |session_id:, id: nil|
155
+ stopped(@ctx[:jobs].stop(id!(id, "job_stop"), session: session_id))
156
+ end
157
+ end
158
+
159
+ # Both refusals name the argument rather than the Ruby that would
160
+ # otherwise report it. An array here is a model reaching for the
161
+ # terminal_open convention, and stringifying it would start a job around
162
+ # a command nobody wrote.
163
+ def command!(command)
164
+ return command if command.is_a?(String) && !command.strip.empty?
165
+
166
+ raise Terret::Tools::Failure,
167
+ "job_start needs a command: one shell command line, as a string. Nothing " \
168
+ "was started."
169
+ end
170
+
171
+ def id!(id, tool)
172
+ return id if id.is_a?(String) && !id.strip.empty?
173
+
174
+ raise Terret::Tools::Failure,
175
+ "#{tool} needs the job id job_start handed back, as a string"
176
+ end
177
+
178
+ # The ledger line is what the model will address the job by for the rest
179
+ # of its life, so it is never omitted.
180
+ def started(id) = "The job is running in the background.\n#{LEDGER}\njob #{id}"
181
+
182
+ def stopped(id)
183
+ "Stopped job #{id}.\n#{LEDGER}\ncollect it once more for anything it wrote on its way out"
184
+ end
185
+
186
+ def render(result)
187
+ body, dropped = cap(scrub(result[:output]))
188
+ remarks = remarks_for(result, body, dropped)
189
+ "#{body.empty? ? '(no new output)' : body}\n#{LEDGER}\n#{remarks.join("\n")}"
190
+ end
191
+
192
+ # Unlike Bash's, the status remark is never silent. "Still running" and
193
+ # "finished" are the two facts a collect exists to establish, and a model
194
+ # left to infer them from an empty result will either poll a job that has
195
+ # been over for minutes or walk away from one that has not started
196
+ # writing yet.
197
+ def remarks_for(result, body, dropped)
198
+ remarks = [status_remark(result)]
199
+ # The dropped bytes are gone, not held back: this collect drained the
200
+ # buffer, so what did not fit the cap is not waiting for the next one.
201
+ # Saying "truncated" without saying that invites a model to collect
202
+ # again for the rest of a result nothing can hand it.
203
+ if dropped.positive?
204
+ remarks << "output truncated at max_output: kept the first #{body.bytesize} bytes " \
205
+ "of rendered output and dropped #{dropped} more, which are gone rather " \
206
+ "than waiting for the next collect"
207
+ end
208
+ # The seam's own cap, hit before this tool ever saw the bytes. Reported
209
+ # separately because it is a different loss: those bytes are gone from
210
+ # the buffer, not merely from this result.
211
+ if result[:truncated]
212
+ remarks << "some of the job's output was dropped before it could be collected; it " \
213
+ "was writing faster than the buffer's cap allows"
214
+ end
215
+ remarks
216
+ end
217
+
218
+ def status_remark(result)
219
+ return "the job is still running" unless result[:status] == :exited
220
+ return "the job has exited with status #{result[:exit_status]}" if result[:exit_status]
221
+
222
+ "the job was stopped before it could report an exit status"
223
+ end
224
+
225
+ # Clamped rather than trusted: a row carrying a negative cap would
226
+ # otherwise byteslice its way to nil and raise on every call, turning one
227
+ # bad config value into a tool that never works.
228
+ def max_output = [config[:max_output] || DEFAULT_MAX_OUTPUT, 0].max
229
+
230
+ # A job's bytes are not guaranteed to be text. The seam preserves
231
+ # whatever the job wrote (that is its job) and the session log refuses
232
+ # invalid UTF-8 at the durable append boundary, so this is the layer
233
+ # where they have to become storable.
234
+ def scrub(output) = output.to_s.scrub
235
+
236
+ def cap(text)
237
+ limit = max_output
238
+ return [text, 0] if text.bytesize <= limit
239
+
240
+ kept = whole_characters(text.byteslice(0, limit))
241
+ [kept, text.bytesize - kept.bytesize]
242
+ end
243
+
244
+ # Cutting at a byte offset can split a character in half, and those
245
+ # halves are bytes this file manufactured — a durable append JSON-encodes
246
+ # the payload, so a manufactured half raises a layer away from the code
247
+ # that broke it. Belt and braces after #scrub, and kept anyway, for the
248
+ # same reason Bash keeps its copy.
249
+ def whole_characters(text)
250
+ text = text.byteslice(0, text.bytesize - 1) until text.valid_encoding?
251
+ text
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module ToolsStd
5
+ # `Task` (docs/subagents.md §4) — Claude Code's name verbatim, per the M7
6
+ # rule: allow lists in the wild are already written against `Task`, and a
7
+ # Terret-native name would buy a translation layer that does nothing but
8
+ # rename, forever. The tool is thin on purpose: it talks to
9
+ # `ctx[:subagents].run` and knows nothing else about what a subagent is.
10
+ #
11
+ # `approval: :never` on a tool that can obviously mutate the world is the
12
+ # one entry here that looks wrong and is not. The metadata describes what
13
+ # THIS call does directly, and this call starts a conversation. Everything
14
+ # the child then does passes the child's own pipeline: its own allow list,
15
+ # its own approvals gate, one decision per actual effect. Gating `Task`
16
+ # itself would ask a human to approve a call whose effects are not knowable
17
+ # until after the approval is granted — the worst possible moment to ask —
18
+ # and would then ask again, correctly, for each real effect inside.
19
+ #
20
+ # `concurrency: :parallel` because a Task call is a whole turn of latency,
21
+ # which is exactly the case the barrier was declared for.
22
+ class Task < Hames::Service
23
+ service_key :tools_std_task
24
+ inject :tools, :loop, :subagents
25
+ config_schema({}) # the Task tool takes no config (see the :subagents seam)
26
+
27
+ # The same literal Bash and WebFetch separate their output with, and it
28
+ # carries the same caveats: a readability device rather than a security
29
+ # boundary — a child could print the line itself — whose actual delivery
30
+ # is that the genuine remarks are always last and always advisory data
31
+ # that nothing downstream acts on.
32
+ LEDGER = "--- terret ---"
33
+
34
+ DESCRIPTION = "Delegate a whole task to a fresh subagent and get back its final answer. " \
35
+ "The subagent starts with an empty conversation: the prompt is everything " \
36
+ "it will see, so state the goal, the context it needs, and what to report " \
37
+ "back. It runs with the same tools and the same permissions you have, and " \
38
+ "its own work is logged in its own session rather than in yours."
39
+
40
+ def start(ctx)
41
+ @ctx = ctx
42
+ register_task
43
+ end
44
+
45
+ # Nothing is captured from config; there is no knob on this row.
46
+ def reconfigure(_config); end
47
+
48
+ private
49
+
50
+ # `ctx:` is passed explicitly: the registry would otherwise record the
51
+ # frame on the context it was started in (the root), so a roster mounted
52
+ # into a forked agent scope would leave a registration behind that
53
+ # outlives the fork.
54
+ def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
55
+ @ctx[:tools].register(name: name, description: description, params: params,
56
+ mutating: mutating, approval: approval,
57
+ concurrency: concurrency, ctx: @ctx, &handler)
58
+ end
59
+
60
+ def register_task
61
+ params = {
62
+ type: "object",
63
+ properties: {
64
+ description: { type: "string",
65
+ description: "A short label for the delegation, three to five words" },
66
+ prompt: { type: "string",
67
+ description: "The subagent's whole instruction; it sees nothing else" }
68
+ },
69
+ required: %w[description prompt]
70
+ }
71
+ # Both are required in the schema and both are defaulted here. A
72
+ # delegation nobody can name is one nobody can follow, and a model
73
+ # writing one without a prompt has made a mistake — but an omitted
74
+ # keyword would cost a whole turn to an ArgumentError, where a
75
+ # defaulted one costs a result the model can read and correct.
76
+ # `description` is a label for a log or a UI line; the child never
77
+ # sees it, so nothing here uses it.
78
+ tool("Task", DESCRIPTION, params, mutating: false, approval: :never,
79
+ concurrency: :parallel) do |session_id:, description: nil, prompt: nil|
80
+ render(delegate(prompt, session_id))
81
+ end
82
+ end
83
+
84
+ # The load-bearing line of this tool. Task is registered on the ROOT
85
+ # context like the rest of the roster, so its closure captures the root
86
+ # and not any agent; what makes the child inherit the CALLER's roster and
87
+ # policy floor is looking the caller up here. `session_id` is injected by
88
+ # the registry and merged last, so a model that writes one into its
89
+ # arguments is naming somebody else's context and simply loses.
90
+ def delegate(prompt, session_id)
91
+ if prompt.nil? || (prompt.is_a?(String) && prompt.strip.empty?)
92
+ raise Terret::Tools::Failure,
93
+ "Task needs a prompt: it is everything the subagent will see. " \
94
+ "Nothing was delegated."
95
+ end
96
+ unless prompt.is_a?(String)
97
+ raise Terret::Tools::Failure,
98
+ "prompt must be a string; got #{prompt.class}. Nothing was delegated."
99
+ end
100
+
101
+ agent = @ctx[:loop].agent_for_session(session_id)
102
+ unless agent
103
+ raise Terret::Tools::Failure,
104
+ "no live agent owns this session, so Task has no context to delegate from"
105
+ end
106
+
107
+ @ctx[:subagents].run(prompt: prompt, ctx: agent.ctx)
108
+ end
109
+
110
+ # The ledger is not cosmetic and it is never omitted: nothing in the
111
+ # parent's log links it to the child's session except this line.
112
+ #
113
+ # A turn that did not complete is reported too. "Had nothing to say" and
114
+ # "was stopped part-way" are different facts about a delegation, and a
115
+ # model shown only the child's last sentence would summarize the second
116
+ # as if it were an answer.
117
+ def render(result)
118
+ text = result.text.to_s
119
+ remarks = ["child session #{result.session_id}"]
120
+ unless result.status == :completed
121
+ remarks << "the subagent's turn ended #{result.status} rather than completing"
122
+ end
123
+ "#{text.empty? ? '(no reply)' : text}\n#{LEDGER}\n#{remarks.join("\n")}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module ToolsStd
5
+ # `terminal_open` / `terminal_input` / `terminal_read` / `terminal_close`
6
+ # (docs/exec.md §5) — the four std tools with no Claude Code equivalent,
7
+ # over ctx[:terminals]'s named long-lived PTYs. A REPL or a dev server
8
+ # stays addressable across a whole turn, which is the difference between
9
+ # this seam and the one-shot spawn behind `Bash`.
10
+ #
11
+ # Every call passes its own session as the owner. Names are scoped per
12
+ # owner on the seam, so that argument is the whole reason one agent
13
+ # cannot read — or close — a live process belonging to another; a
14
+ # constant here would quietly merge every agent's terminals into the one
15
+ # namespace the seam was built to keep apart.
16
+ #
17
+ # Neither `cwd` nor `env` is a tool argument, deliberately. A terminal is
18
+ # spawned directly rather than through ctx[:fs], so nothing would contain
19
+ # a cwd the model chose, and an env the model writes is a place for a
20
+ # credential to be laundered into a child process. Both stay the row's
21
+ # decision, where a deployment can see them.
22
+ class Terminals < Hames::Service
23
+ service_key :tools_std_terminals
24
+ inject :tools, :terminals
25
+ config_schema({}) # the terminal tools take no config (see the :terminals seam)
26
+
27
+ def start(ctx)
28
+ @ctx = ctx
29
+ register_open
30
+ register_input
31
+ register_read
32
+ register_close
33
+ end
34
+
35
+ # Nothing is captured from config here: every knob these tools reach
36
+ # (the cap, the read timeout, the cwd) belongs to the terminals row and
37
+ # is read there, at call time. Saying so beats letting the base class
38
+ # warn that this row needs a remount when it does not.
39
+ def reconfigure(_config); end
40
+
41
+ private
42
+
43
+ # `ctx:` is passed explicitly: the registry would otherwise record the
44
+ # frame on the context it was started in (the root), so a roster
45
+ # mounted into a forked agent scope would leave registrations behind
46
+ # that outlive the fork — a disposed agent with a tool of its own that
47
+ # can still spawn processes.
48
+ # All four are `mutating: true`, `terminal_read` included, and that is
49
+ # not a rounding-up. Reading a PTY CONSUMES the stream: the bytes it
50
+ # returns are gone for every later read, so two identical calls give
51
+ # different answers and neither can be replayed. It is not reading in
52
+ # the sense the Read tool means, where the file is still there
53
+ # afterwards. The cost is that a deployment gating mutations asks about
54
+ # terminal_read too, which is the right trade — an approvals deployment
55
+ # is interactive by definition, and the alternative is a tool that
56
+ # quietly drains another tool's output while claiming to observe it.
57
+ def tool(name, description, params, &handler)
58
+ @ctx[:tools].register(name: name, description: description, params: params,
59
+ mutating: true, approval: :policy, concurrency: :serial,
60
+ ctx: @ctx, &handler)
61
+ end
62
+
63
+ def object_schema(properties, required)
64
+ { type: "object", properties: properties, required: required }
65
+ end
66
+
67
+ def name_property = { type: "string", description: "The terminal's name within this session" }
68
+
69
+ def register_open
70
+ params = object_schema(
71
+ { name: name_property,
72
+ argv: { type: "array", items: { type: "string" },
73
+ description: "The command and its arguments, e.g. [\"python3\", \"-i\"]" } },
74
+ %w[name argv]
75
+ )
76
+ description = "Start a long-lived terminal (a PTY) that stays open across calls. No shell " \
77
+ "interprets the argv, so use [\"bash\", \"-lc\", \"...\"] for pipelines or " \
78
+ "redirection. The terminal keeps running until terminal_close."
79
+ tool("terminal_open", description, params) do |name:, argv:, session_id:|
80
+ open_terminal(name, argv!(argv), session_id)
81
+ end
82
+ end
83
+
84
+ def register_input
85
+ params = object_schema(
86
+ { name: name_property,
87
+ text: { type: "string",
88
+ description: "Text to type; include a trailing newline to submit a line" } },
89
+ %w[name text]
90
+ )
91
+ tool("terminal_input", "Type text into an open terminal. Nothing is submitted until a " \
92
+ "newline is sent, exactly as at a keyboard.", params) do |name:, text:, session_id:|
93
+ @ctx[:terminals].input(name, text, session: session_id)
94
+ "Typed #{text.to_s.bytesize} bytes into terminal #{name}"
95
+ end
96
+ end
97
+
98
+ def register_read
99
+ params = object_schema(
100
+ { name: name_property,
101
+ timeout: { type: "integer",
102
+ description: "Optional milliseconds to wait for output before giving up" } },
103
+ %w[name]
104
+ )
105
+ tool("terminal_read", "Read whatever an open terminal has said since the last read. " \
106
+ "Returns empty-handed rather than waiting for a terminal that has " \
107
+ "nothing to say.", params) do |name:, session_id:, timeout: nil|
108
+ read_terminal(name, session_id, timeout)
109
+ end
110
+ end
111
+
112
+ def register_close
113
+ tool("terminal_close", "Close a terminal and reap its process, freeing the name.",
114
+ object_schema({ name: name_property }, %w[name])) do |name:, session_id:|
115
+ # The seam makes closing a name that is not open a no-op, because
116
+ # disposal runs over sets that may already be partly closed. The
117
+ # tool says which of the two happened rather than inventing a
118
+ # failure the seam deliberately does not raise.
119
+ if @ctx[:terminals].close(name, session: session_id).nil?
120
+ "No terminal named #{name} was open"
121
+ else
122
+ "Closed terminal #{name}"
123
+ end
124
+ end
125
+ end
126
+
127
+ # Named away from `open` and `read`: Kernel's own methods carry those
128
+ # names, and Kernel#open runs a command when handed a string starting
129
+ # with a pipe. A private helper that shadows it is one rename away from
130
+ # falling through to it, in the one class where that would hand a model
131
+ # a spawn nobody gated.
132
+ # argv arrives as whatever JSON the model wrote, and two shapes of it
133
+ # reach the kernel as something worse than an error. An empty list
134
+ # becomes a nil program and surfaces as "TypeError: no implicit
135
+ # conversion of nil into String" from inside exec; a nil element
136
+ # stringifies into an empty argument the kernel accepts happily, so the
137
+ # terminal opens around a command nobody wrote. Both are refused rather
138
+ # than repaired — repairing an argv is a guess about what the caller
139
+ # meant, made at the moment it is spawning a process.
140
+ def argv!(argv)
141
+ list = argv.is_a?(Array) ? argv : [argv]
142
+ raise Terret::Tools::Failure, "argv must name a command to run" if list.empty?
143
+
144
+ bad = list.reject { |arg| arg.is_a?(String) }
145
+ return list if bad.empty?
146
+
147
+ raise Terret::Tools::Failure,
148
+ "every argv element must be a string; got #{bad.first.inspect}"
149
+ end
150
+
151
+ def open_terminal(name, argv, session_id)
152
+ terminal = @ctx[:terminals].open(name, argv, session: session_id)
153
+ "Opened terminal #{terminal.name} (pid #{terminal.pid})"
154
+ rescue Errno::ENOENT
155
+ # The three errnos a spawn raises for an argv that cannot become a
156
+ # process. Left alone the pipeline renders Ruby's own wording, and
157
+ # EACCES's is actively misleading: "Permission denied - fork failed"
158
+ # names the one call that did NOT fail — the fork worked, the exec
159
+ # did not — so a model reads it as a broken harness and retries
160
+ # instead of fixing the argv it wrote. A Failure renders message-only,
161
+ # and the seam registers nothing before the spawn returns, so there is
162
+ # never a half-open terminal to mention.
163
+ #
164
+ # Deliberately not `SystemCallError`: EMFILE and ENFILE mean the
165
+ # harness is out of descriptors, which is not the caller's mistake to
166
+ # fix and must keep crashing loudly rather than being explained to a
167
+ # model as a bad command.
168
+ raise Terret::Tools::Failure,
169
+ "could not start #{argv.first.inspect}: no such command, or the terminal's " \
170
+ "working directory is gone; nothing was opened"
171
+ rescue Errno::EACCES, Errno::ENOEXEC
172
+ raise Terret::Tools::Failure,
173
+ "could not start #{argv.first.inspect}: not executable — a directory, or a file " \
174
+ "without the execute bit; nothing was opened"
175
+ end
176
+
177
+ def read_terminal(name, session_id, timeout)
178
+ chunk = @ctx[:terminals].read(name, session: session_id,
179
+ timeout: timeout.nil? ? nil : timeout / 1000.0)
180
+ # nil is the terminal's process being gone, "" is it being alive with
181
+ # nothing to say — two different answers, and a model that cannot
182
+ # tell them apart will either keep polling a dead terminal or close a
183
+ # live one. The name stays open in both cases; reading is not
184
+ # disposal.
185
+ return "(the terminal's process has ended; terminal_close frees the name)" if chunk.nil?
186
+ return "(nothing to read)" if chunk.empty?
187
+
188
+ # Same stance as Bash's: the seam preserves whatever the child wrote,
189
+ # the session log refuses invalid UTF-8 at the durable append
190
+ # boundary, so this is the layer that has to make it storable.
191
+ chunk.scrub
192
+ end
193
+ end
194
+ end
195
+ end