harnex 0.29.1 → 0.30.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e36a59564be22ba55cda2815e3e72b59b887bb2a4e0417be776295ef1cb10b82
4
- data.tar.gz: 0a9b1355a5bc153e4d6fdd6ef79df81f022c5b6a75f97de0a8f91ef52353e526
3
+ metadata.gz: b4b50e456b0b9be717e037a0596b942273e2e4c192b80d697fc502a66cc4c220
4
+ data.tar.gz: 77ed9e03656bfff5f68064bb4dcb64f898d1de6b5d599ad30450ac51f8a1938e
5
5
  SHA512:
6
- metadata.gz: 1794b1c8190bf40109732a821470d9a24772be9e68e46b9c49732ac46bd24fb73a88d07d0097dece3ec43e3b164932903d69ffda7fa6b4f18b244989990c8a52
7
- data.tar.gz: 8163c5eb86dacb6174868459fcc9bba2fb87b66c503e61a96f38baefbe2ca93423df3a84fd07da626927402fe5432f4f541eec202a7b4cc4fde131e1b9ff7de4
6
+ metadata.gz: 85ca4102b12fe2a0e109c1fdfc643c96d80677b267c0b658cb4ff5787a72075e0d77e9d98a29bf401fa5975502cfc5fca891fe62c63ef51a92b8f626e2c60d04
7
+ data.tar.gz: 39b143e75ade8b68254742f6ea23eee43fde615c01de9c273d221b681b8b913f7c35e87b6bf9a79f84e290602790641fdb5a96e84c7a75fedad602b1ef6718d6
data/CHANGELOG.md CHANGED
@@ -1,8 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0] - 2026-10-25 | 02:55 AM | IST
4
+
5
+ Holm's first nights with lanes (Holm Queue 167): #89–#92. Local install.
6
+
7
+ ### Fixed
8
+
9
+ - **Lane names collided** (#89). Identical briefs in sibling worktrees of a
10
+ bare repo all became `pi-read-carry-out-brief`, and `harnex result NAME`
11
+ then failed with `several lanes are named …`.
12
+ - A bare layout (`repo/.bare` plus sibling worktrees) is one repo: its main
13
+ checkout is the worktree on the bare repo's `HEAD` branch, so every
14
+ worktree shares one lane namespace and `harnex lanes` shows them all.
15
+ - Generated names skip every name another lane still holds in any repo
16
+ (working, uncollected, or an unlanded worktree); `--name`, `--worktree`
17
+ and `ask --id` refuse a name while another repo's worker runs under it;
18
+ `result`/`reply` find a working lane from anywhere.
19
+ - A lane working in another worktree is named after it
20
+ (`pi-night-1024_s3`); brief openers such as "You are a … lane" and "carry
21
+ out the brief" no longer make the name.
22
+ - `delegate --worktree` refuses a `--cwd` that is already another worktree
23
+ of the repo (it would have branched from the main checkout instead).
24
+ - **The `gpt` agent invented a `worktree`** from brief prose (#89). It now
25
+ sets `worktree`, `name` and `cwd` only from header lines, takes a new
26
+ `name:` header, and passes `cwd:` on to `result` and `reply`.
27
+ - **A lane in place reported other people's commits** (#91). Its footer
28
+ counts only commits that appeared while the worker ran a git command, and
29
+ names the rest: `0 commits (HEAD also moved 6 commits by others)`.
30
+ - **Messages sent straight to a worker were invisible** to the session that
31
+ started its lane (#92). `harnex send` now records who sent a message, and a
32
+ lane's result lists messages from anyone but the lane's owner and its own
33
+ `harnex reply` (`interjected: 1 message … from a terminal (harnex send):
34
+ "…"`). When a message reached the worker after it had answered, the result
35
+ shows the answers it replaced first, the original task's report among them.
36
+
37
+ ### Added
38
+
39
+ - `.harnex/config.json` `lanes` templates put worktree lanes where the repo
40
+ keeps its worktrees (#90): `{"lanes": {"worktree_path": "../{name}",
41
+ "branch": "night/{mm}{dd}_{name}"}}` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`).
42
+ `land` and `discard` follow each lane's own path and branch.
43
+ - MCP `ask` takes `name`.
44
+
3
45
  ## [0.29.1] - 2026-10-23 | 10:31 AM | IST
4
46
 
5
- Local-only fix release for #88: lanes in repos with a phase allowlist.
47
+ Fix release for #88: lanes in repos with a phase allowlist. Published to
48
+ RubyGems on 2026-10-25 | 02:14 AM | IST.
6
49
 
7
50
  ### Fixed
8
51
 
data/agents/gpt.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gpt
3
- description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir>`. Write a self-contained brief: the GPT worker has not seen this conversation."
3
+ description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir>` (work in that directory, e.g. a sibling worktree), `name: NAME` (the lane's name; default: the cwd worktree's name or the brief's first words). Write a self-contained brief: the GPT worker has not seen this conversation."
4
4
  tools: mcp__harnex__ask, mcp__harnex__delegate, mcp__harnex__reply, mcp__harnex__result
5
5
  mcpServers:
6
6
  - harnex
@@ -16,11 +16,16 @@ return its text.
16
16
  ## 1. Split the prompt
17
17
 
18
18
  The prompt may open with header lines `key: value` using only these keys:
19
- `worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`. The header ends at
20
- the first line that is not one of them; drop one blank line after it.
19
+ `worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`, `name`. The header
20
+ ends at the first line that is not one of them; drop one blank line after it.
21
21
  Everything after the header is the brief, kept exactly as written. No header
22
22
  lines means the whole prompt is the brief.
23
23
 
24
+ Only header lines set tool arguments. Never take a `worktree`, `name` or
25
+ `cwd` from the brief's wording, even when the brief names a worktree, a
26
+ branch or a directory: the brief goes to the worker as it is, and the worker
27
+ reads it.
28
+
24
29
  ## 2. Call one tool
25
30
 
26
31
  Pick the tool:
@@ -35,23 +40,26 @@ Then:
35
40
 
36
41
  - **`delegate`:** call it with
37
42
  - `task` = the brief;
38
- - `worktree` = NAME, if given;
43
+ - `worktree` = NAME, only if the header has a `worktree:` line;
44
+ - `name` = NAME, if the header has a `name:` line and no `worktree:` line;
39
45
  - and `model`, `effort`, `cwd` if given.
40
46
  - **`ask`:** call it with
41
47
  - `question` = the brief;
42
- - and `model`, `effort`, `cwd` if given, plus `fallback_model` from
48
+ - and `model`, `effort`, `cwd`, `name` if given, plus `fallback_model` from
43
49
  `fallback:`.
44
50
  - **A later message in this conversation** (a follow-up) goes to `reply`, with
45
51
  - `name` = the lane named in the last result's `── NAME:` footer;
46
- - `message` = the new message, exactly as written.
52
+ - `message` = the new message, exactly as written;
53
+ - and `cwd` if the first prompt's header gave one.
47
54
 
48
55
  Pass every string exactly as written.
49
56
 
50
57
  ## 3. Still working
51
58
 
52
59
  If the tool's text ends with a footer saying `still working`, call `result`
53
- with `names` = [that lane name]. Repeat while it says still working, up to 12
54
- times. Never start a second lane for the same brief.
60
+ with `names` = [that lane name], and `cwd` if the header gave one. Repeat
61
+ while it says still working, up to 12 times. Never start a second lane for
62
+ the same brief.
55
63
 
56
64
  ## 4. Return
57
65
 
data/docs/agents.md CHANGED
@@ -59,10 +59,13 @@ Optional header lines, then a blank line, then the brief:
59
59
  | `mode: read` | Read-only, even if the brief mentions commands |
60
60
  | `model: provider/model`, `effort: low\|medium\|high` | Pick the worker's model |
61
61
  | `fallback: provider/model` | Retry a read-only ask on another model |
62
- | `cwd: DIR` | Work in another directory |
62
+ | `cwd: DIR` | Work in another directory, e.g. a sibling worktree (follow-ups stay there) |
63
+ | `name: NAME` | Name the lane (default: the `cwd` worktree's name, else the brief's first words) |
63
64
 
64
65
  Without a header, questions and reviews go to a read-only worker, and a brief
65
- that asks to run something or change files goes to a worker that can.
66
+ that asks to run something or change files goes to a worker that can. Only
67
+ header lines set these: a brief that mentions a worktree or a branch is passed
68
+ on as it is, never turned into a `worktree`.
66
69
  The definition ships with harnex as [`agents/gpt.md`](../agents/gpt.md).
67
70
 
68
71
  ### The CLI from Claude's Bash tool
data/docs/lanes.md CHANGED
@@ -5,7 +5,8 @@ its follow-ups. You start one with `harnex ask` or `harnex delegate`, collect
5
5
  it with `harnex result`, continue it with `harnex reply`, and finish it with
6
6
  `harnex land` or `harnex discard`. Every lane has a name, runs in a visible
7
7
  tmux window named after it, and leaves a small record so any later command can
8
- find it by name from anywhere in the repository.
8
+ find it by name from anywhere in the repository (from any worktree of it, a
9
+ bare-repo layout included).
9
10
 
10
11
  Lanes are the agent-facing layer of harnex. Everything here is also available
11
12
  as native tools through [`harnex mcp`](mcp.md), and the low-level `run`, `send`,
@@ -65,10 +66,19 @@ cost, commits and the next commands.
65
66
  `harnex/NAME` from `HEAD`. The worker is told to commit there. Parallel lanes
66
67
  never collide, and your checkout is untouched until you land. If your checkout
67
68
  has uncommitted changes, harnex warns that the worktree starts from `HEAD`.
68
- - **Without `--worktree`** the worker works in the current directory, which is
69
- right for tasks that only run or inspect things (tests, a benchmark, a long
70
- command). `--name NAME` names the lane; otherwise harnex derives a name from
71
- the task (`pi-slow-bench`).
69
+ A repo that keeps its worktrees elsewhere says so in `.harnex/config.json`
70
+ (see [Where things live](#where-things-live)).
71
+ - **Without `--worktree`** the worker works in the current directory, or in
72
+ `--cwd DIR`, which is right for tasks that only run or inspect things (tests,
73
+ a benchmark, a long command) and for a worktree you made yourself.
74
+ - **Names.** `--name NAME` names the lane. Otherwise a lane working in another
75
+ worktree of the repo is named after it (`--cwd ../night-1024_s3` gives
76
+ `pi-night-1024_s3`), and any other lane after the task's first words
77
+ (`pi-slow-bench`, then `pi-slow-bench-2`). Generated names skip every name
78
+ another lane still holds anywhere (working, uncollected, or a worktree not
79
+ yet landed), and a name you choose is refused while another repo's worker
80
+ is running under it, so `harnex result NAME` and `reply NAME` find a
81
+ working lane from anywhere.
72
82
  - **`--file PATH`** reads a long brief from a file. Briefs must be
73
83
  self-contained: the worker has not seen your conversation. Name the files, the
74
84
  expected behavior and how to verify it.
@@ -126,6 +136,11 @@ or bare `land`.
126
136
  `harnex discard NAME` stops the lane if it is still working and deletes its
127
137
  worktree and branch without merging.
128
138
 
139
+ **Lanes in place.** For a lane without a worktree, the footer counts only the
140
+ commits the worker made (harnex watches `HEAD` around each git command the
141
+ worker runs). Commits someone else made in the same checkout meanwhile are
142
+ named apart: `0 commits (HEAD also moved 3 commits by others)`.
143
+
129
144
  ## Parallel lanes
130
145
 
131
146
  ```bash
@@ -153,6 +168,26 @@ mid-task as a follow-up; a lane that has finished is relaunched on its own Pi
153
168
  session (`pi --session`), so it remembers everything from the earlier turns.
154
169
  The footer's cost is the lane's total so far.
155
170
 
171
+ **Messages from someone else.** Anyone can `harnex send` to a lane's worker
172
+ (the owner from another terminal, a buddy). The lane's result then says so,
173
+ so the session that started the lane is not left guessing:
174
+
175
+ ```text
176
+ [earlier reply this turn, before another message reached the worker]
177
+ Review: PASS (0 P1, 2 P2) ...
178
+
179
+ [latest reply]
180
+ Understood; the report is now in /tmp/review.md.
181
+
182
+ ── review: done in 6m10s · gpt-6.1-sol · $0.41 · 0 commits
183
+ interjected: 1 message reached the worker from outside this lane (the reply above may answer it; all: `harnex events --id review`):
184
+ 23:26:04 from a terminal (harnex send): "Write the report to /tmp/review.md instead"
185
+ ```
186
+
187
+ The worker's earlier answers in the same turn (the original task's report
188
+ among them) come first; your own `harnex reply` follow-ups are never listed as
189
+ interjections.
190
+
156
191
  ## When a model fails
157
192
 
158
193
  ```text
@@ -188,7 +223,18 @@ worker's tmux window, and the window itself is always there to watch.
188
223
  repo, worktree, branch, model, owner, Pi session, cost, last result). Closed
189
224
  lanes are pruned after 30 days; open lanes never are.
190
225
  - **Worktrees:** `.harnex/worktrees/NAME` inside your repository, on branch
191
- `harnex/NAME`.
226
+ `harnex/NAME`. To keep them beside the main checkout instead, set templates
227
+ in the repo's `.harnex/config.json` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`;
228
+ the path is relative to the main checkout). `land` and `discard` follow the
229
+ lane's own path and branch:
230
+
231
+ ```json
232
+ { "lanes": { "worktree_path": "../night-{mm}{dd}_{name}", "branch": "night/{mm}{dd}_{name}" } }
233
+ ```
234
+
235
+ - **Bare-repo layouts** (`repo/.bare` plus sibling worktrees): the main
236
+ checkout is the worktree on the bare repo's `HEAD` branch (`master`), and
237
+ every worktree of the repo shares its lanes.
192
238
  - **`.harnex/`** is added to the clone's `.git/info/exclude` the first time
193
239
  harnex writes there, so lane worktrees and dispatch rows never show up as
194
240
  untracked work. Nothing tracked changes.
@@ -112,6 +112,9 @@ module Harnex
112
112
 
113
113
  def register_lane(id, model)
114
114
  cwd = File.expand_path(@options[:cwd] || Dir.pwd)
115
+ if (other = Lanes.live_holder(id))
116
+ raise OptionParser::InvalidArgument, "a worker named #{id} is working in #{other['repo_root']}; pick another --id"
117
+ end
115
118
  Lanes.save(
116
119
  "name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
117
120
  "cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
@@ -204,7 +207,8 @@ module Harnex
204
207
  answer = full_answer(turn)
205
208
  case exit_code
206
209
  when 0
207
- @out.puts answer.to_s
210
+ lane = answered_lane(result["id"])
211
+ @out.puts(lane ? Lanes.render_answer(lane, turn.merge("final_message" => answer.to_s, "final_message_truncated" => false)) : answer.to_s)
208
212
  @err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
209
213
  when Waiter::DONE_EXIT_TIMEOUT
210
214
  @err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
@@ -216,6 +220,12 @@ module Harnex
216
220
  exit_code
217
221
  end
218
222
 
223
+ def answered_lane(id)
224
+ id && Lanes.resolve(id, from: File.expand_path(@options[:cwd] || Dir.pwd))
225
+ rescue OptionParser::InvalidArgument
226
+ nil
227
+ end
228
+
219
229
  def full_answer(turn)
220
230
  path = turn["final_message_path"].to_s
221
231
  return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
@@ -17,8 +17,12 @@ module Harnex
17
17
  Options:
18
18
  --worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
19
19
  harnex/NAME); finish with `harnex land NAME` or
20
- `harnex discard NAME`
21
- --name NAME Lane name without a worktree (default: from the task)
20
+ `harnex discard NAME`. The repo's .harnex/config.json
21
+ can move it: {"lanes": {"worktree_path": "../{name}",
22
+ "branch": "night/{mm}{dd}_{name}"}}
23
+ --name NAME Lane name without a worktree (default: the --cwd
24
+ worktree's name, else from the task). A working
25
+ lane's name is unique across every repo
22
26
  --cwd DIR Directory the worker works in (default: current directory)
23
27
  --file PATH Read the task from PATH
24
28
  --model NAME Pi model (provider/model); default: Pi's own default
@@ -86,9 +90,10 @@ module Harnex
86
90
  root = Lanes.main_root(workdir)
87
91
  raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
88
92
 
93
+ refuse_nested_worktree!(root, workdir) if @options[:worktree]
89
94
  Lanes.sweep_merged(root)
90
95
  root ||= workdir
91
- name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
96
+ name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task, cwd: workdir))
92
97
  ensure_free!(root, name)
93
98
  lane = {
94
99
  "name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
@@ -125,11 +130,27 @@ module Harnex
125
130
  "lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
126
131
  "or `harnex land #{name}` / `harnex discard #{name}` first"
127
132
  end
133
+ if (other = Lanes.live_holder(name)) && other["repo_root"] != root
134
+ raise OptionParser::InvalidArgument,
135
+ "a worker named #{name} is working in #{other['repo_root']}; working lanes' names are unique, " \
136
+ "so pick another name (or wait: `harnex result #{name}`)"
137
+ end
128
138
  return unless Harnex.read_registry(root, name)
129
139
 
130
140
  raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
131
141
  end
132
142
 
143
+ # #89: --cwd in a sibling worktree plus --worktree would branch a new
144
+ # worktree from the main checkout, not from --cwd; say so instead.
145
+ def refuse_nested_worktree!(root, workdir)
146
+ top = Lanes.toplevel(workdir)
147
+ return if top.nil? || root.nil? || Lanes.same_dir?(top, root)
148
+
149
+ raise OptionParser::InvalidArgument,
150
+ "--cwd #{top} is already a worktree of #{root}: drop --worktree to work there in place, " \
151
+ "or drop --cwd to start a new worktree from the main checkout"
152
+ end
153
+
133
154
  def warn_uncommitted(root)
134
155
  dirty = Lanes.dirty_paths(root)
135
156
  return if dirty.empty?
@@ -61,7 +61,8 @@ module Harnex
61
61
  def follow_up(lane, message)
62
62
  argv = @harnex_command + ["send", "--id", lane["name"], "--repo", lane["run_root"], "--message", message]
63
63
  argv += ["--wait-done"] + (@options[:timeout] ? ["--timeout", @options[:timeout].to_s] : []) if @options[:wait]
64
- stdout, stderr, status = Open3.capture3(*argv)
64
+ # Marked as the lane's own follow-up, not an interjection (#92).
65
+ stdout, stderr, status = Open3.capture3({ "HARNEX_SEND_VIA" => "reply" }, *argv)
65
66
  result = Lanes.last_json(stdout)
66
67
  raise Lanes::LaunchError, "harnex could not reach lane #{lane['name']}: #{stderr.strip.lines.last}" if result.empty?
67
68
 
@@ -71,7 +71,7 @@ module Harnex
71
71
  text = if @options[:json]
72
72
  JSON.generate(lane: lane["name"], exit_code: exit_code, state: lane["state"], result: turn)
73
73
  elsif lane["kind"] == "ask" && exit_code.zero?
74
- Lanes.final_message(turn)
74
+ Lanes.render_answer(lane, turn)
75
75
  else
76
76
  Lanes.render(lane, turn, exit_code)
77
77
  end
@@ -141,7 +141,8 @@ module Harnex
141
141
  text: text,
142
142
  submit: @options[:submit],
143
143
  enter_only: @options[:submit_only],
144
- force: @options[:force]
144
+ force: @options[:force],
145
+ from: Harnex.sender_identity
145
146
  )
146
147
 
147
148
  Net::HTTP.start(
@@ -488,7 +488,8 @@ module Harnex
488
488
  }
489
489
  payload[:turn] = event["turn"] if event["turn"]
490
490
  payload[:follow_ups] = event["follow_ups"] if event["follow_ups"]
491
- %w[end_sha commits turn_commits model final_message final_message_path final_message_truncated].each do |key|
491
+ %w[end_sha commits turn_commits model final_message final_message_path final_message_truncated
492
+ earlier_replies].each do |key|
492
493
  payload[key.to_sym] = event[key] if event.key?(key)
493
494
  end
494
495
  exit_code = 0
data/lib/harnex/config.rb CHANGED
@@ -6,6 +6,7 @@ module Harnex
6
6
  PHASE_POLICIES = %w[warn reject].freeze
7
7
  RETENTION_DIRS = %w[events output receipts].freeze
8
8
  RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
9
+ LANE_TEMPLATES = %w[worktree_path branch].freeze
9
10
  RETENTION_ENV = {
10
11
  "events" => {
11
12
  "max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
@@ -38,6 +39,10 @@ module Harnex
38
39
  def retention
39
40
  data.is_a?(Hash) ? data["retention"] : nil
40
41
  end
42
+
43
+ def lanes
44
+ data.is_a?(Hash) ? data["lanes"] : nil
45
+ end
41
46
  end
42
47
 
43
48
  module_function
@@ -63,6 +68,22 @@ module Harnex
63
68
 
64
69
  validate_phase!(document["phase"], path) if document.key?("phase")
65
70
  validate_retention!(document["retention"], path) if document.key?("retention")
71
+ validate_lanes!(document["lanes"], path) if document.key?("lanes")
72
+ end
73
+
74
+ # #90: where worktree lanes live and what their branches are called,
75
+ # e.g. {"worktree_path": "../{name}", "branch": "night/{mm}{dd}_{name}"}.
76
+ def validate_lanes!(lanes, path)
77
+ raise ConfigError, "#{path}: $.lanes must be an object" unless lanes.is_a?(Hash)
78
+
79
+ lanes.each do |key, template|
80
+ unless LANE_TEMPLATES.include?(key)
81
+ raise ConfigError, "#{path}: $.lanes.#{key} is not supported (expected #{LANE_TEMPLATES.join(' or ')})"
82
+ end
83
+ next if template.is_a?(String) && template.include?("{name}")
84
+
85
+ raise ConfigError, "#{path}: $.lanes.#{key} must be a string containing {name}"
86
+ end
66
87
  end
67
88
 
68
89
  def validate_phase!(phase, path)
data/lib/harnex/core.rb CHANGED
@@ -450,6 +450,17 @@ module Harnex
450
450
  nil
451
451
  end
452
452
 
453
+ # #92: who is sending a message, for the receiving lane's footer. `via`
454
+ # is "reply" when `harnex reply` sends on the lane's behalf
455
+ # (HARNEX_SEND_VIA), else "send".
456
+ def sender_identity(env = ENV)
457
+ {
458
+ "via" => env["HARNEX_SEND_VIA"].to_s.strip.empty? ? "send" : env["HARNEX_SEND_VIA"].strip,
459
+ "session" => env["HARNEX_ID"].to_s.strip,
460
+ "owner" => ambient_owner_id(env).to_s
461
+ }.reject { |_key, value| value.empty? }
462
+ end
463
+
453
464
  # How to invoke this harnex in a printed hint: the checkout's bin when
454
465
  # running from source, plain `harnex` for the installed gem.
455
466
  def cli_command
data/lib/harnex/lanes.rb CHANGED
@@ -74,20 +74,45 @@ module Harnex
74
74
  end
75
75
 
76
76
  # A name resolves to this repo's lane first, then to the only lane
77
- # anywhere with that name.
77
+ # anywhere whose worker is running (those names are unique, #89), then
78
+ # in flight, open, or at all.
78
79
  def resolve(name, from: Dir.pwd)
79
80
  root = main_root(from)
80
81
  lane = root && load(root, name)
81
82
  return lane if lane
82
83
 
83
84
  matches = all.select { |candidate| candidate["name"] == name }
84
- return matches.first if matches.one?
85
85
  raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
86
86
 
87
+ [matches.select { |l| open?(l) && live?(l) }, matches.select { |l| in_flight?(l) },
88
+ matches.select { |l| open?(l) }, matches].each do |group|
89
+ return group.first if group.one?
90
+ end
87
91
  raise OptionParser::InvalidArgument,
88
92
  "several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
89
93
  end
90
94
 
95
+ # Working (or finished but uncollected), or a worktree not yet landed,
96
+ # in a repo that still exists.
97
+ def in_flight?(lane)
98
+ open?(lane) && !never_started?(lane) && (lane["state"] == "running" || !lane["branch"].nil?) &&
99
+ File.directory?(lane["repo_root"].to_s)
100
+ end
101
+
102
+ # #89: the in-flight lane anywhere that holds `name`, if any. Generated
103
+ # names skip these, so `harnex result NAME` and `reply NAME` find their
104
+ # lane from anywhere.
105
+ def holder(name)
106
+ all.find { |lane| lane["name"] == name && in_flight?(lane) }
107
+ end
108
+
109
+ # The lane anywhere whose worker is running under `name`, if any. A name
110
+ # someone chooses is refused only while another repo's worker has it, so
111
+ # stale lanes elsewhere (an old eval, an abandoned clone) never block it.
112
+ def live_holder(name)
113
+ all.find { |lane| lane["name"] == name && open?(lane) && live?(lane) }
114
+ end
115
+
91
116
  # Lanes this caller started in this repo whose current turn nobody has
92
117
  # collected yet.
93
118
  def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
@@ -98,20 +123,32 @@ module Harnex
98
123
 
99
124
  FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
100
125
  run use add make have has can could would should will do does just then also your you
101
- our my me i we gpt worker workers task repo repository git command file files code].freeze
102
-
103
- def generate_name(root, task, prefix: "pi")
104
- words = task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) }.first(4)
105
- base = ([prefix] + words).join("-")[0, 40].sub(/-+\z/, "")
126
+ our my me i we gpt worker workers task repo repository git command file files code
127
+ are is be carry out brief lane].freeze
128
+
129
+ # A lane working in another worktree of the repo is named after that
130
+ # worktree (`pi-night-1024_s3`); otherwise after the task's first words.
131
+ # Either way no repo has the name in flight (#89).
132
+ def generate_name(root, task, cwd: nil, prefix: "pi")
133
+ base = ([prefix] + name_words(root, task, cwd)).join("-")[0, 40].sub(/[-_.]+\z/, "")
106
134
  name = base
107
135
  index = 2
108
- while open?(load(root, name)) || Harnex.read_registry(root, name)
136
+ while open?(load(root, name)) || holder(name) || Harnex.read_registry(root, name)
109
137
  name = "#{base}-#{index}"
110
138
  index += 1
111
139
  end
112
140
  name
113
141
  end
114
142
 
143
+ def name_words(root, task, cwd)
144
+ top = cwd && toplevel(cwd)
145
+ if top && root && !same_dir?(top, root)
146
+ slug = File.basename(top).downcase.gsub(/[^a-z0-9._-]+/, "-").sub(/\A[^a-z0-9]+/, "")
147
+ return [slug] unless slug.empty?
148
+ end
149
+ task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) || word.match?(/\A[a-z]\z/) }.first(4)
150
+ end
151
+
115
152
  def live?(lane)
116
153
  registry = Harnex.read_registry(lane["run_root"], lane["name"])
117
154
  !registry.nil? && Harnex.alive_pid?(registry["pid"])
@@ -155,17 +192,43 @@ module Harnex
155
192
  end
156
193
 
157
194
  # The main checkout of the repo that contains `path` (also from inside one
158
- # of its worktrees), or nil outside git.
195
+ # of its worktrees), or nil outside git. In a bare layout (`repo/.bare`
196
+ # plus sibling worktrees, #89) it is the worktree on the bare repo's HEAD
197
+ # branch, so every worktree of the repo shares one lane namespace.
159
198
  def main_root(path)
160
199
  common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
161
200
  return nil unless ok
162
201
 
163
202
  return File.dirname(common) if File.basename(common) == ".git"
164
203
 
204
+ bare_main_worktree(path, common) || toplevel(path)
205
+ end
206
+
207
+ def same_dir?(one, two)
208
+ File.realpath(one.to_s) == File.realpath(two.to_s)
209
+ rescue SystemCallError
210
+ File.expand_path(one.to_s) == File.expand_path(two.to_s)
211
+ end
212
+
213
+ def toplevel(path)
165
214
  top, _err, ok = git(path, "rev-parse", "--show-toplevel")
166
215
  ok ? top : nil
167
216
  end
168
217
 
218
+ def bare_main_worktree(path, common)
219
+ list, _err, ok = git(path, "worktree", "list", "--porcelain")
220
+ return nil unless ok
221
+
222
+ entries = list.split(/\n{2,}/).map do |block|
223
+ block.lines.to_h { |line| line.chomp.split(" ", 2).then { |key, value| [key, value.to_s] } }
224
+ end
225
+ return nil unless entries.first&.key?("bare")
226
+
227
+ head, _err, ok = git(common, "symbolic-ref", "-q", "HEAD")
228
+ main = ok && entries.find { |entry| entry["branch"] == head && File.directory?(entry["worktree"].to_s) }
229
+ main ? main["worktree"] : nil
230
+ end
231
+
169
232
  def head_sha(dir)
170
233
  sha, _err, ok = git(dir, "rev-parse", "HEAD")
171
234
  ok ? sha : nil
@@ -176,10 +239,28 @@ module Harnex
176
239
  ok ? out.lines.map { |line| line[3..].to_s.strip } : []
177
240
  end
178
241
 
242
+ # #90: a worktree lane lives at .harnex/worktrees/NAME on harnex/NAME,
243
+ # unless the repo's .harnex/config.json `lanes` templates say otherwise
244
+ # ({name}, {yyyy}, {mm}, {dd}; worktree_path is relative to the main
245
+ # checkout, so "../{name}" puts lanes beside it).
246
+ def worktree_layout(root, name, now: Time.now)
247
+ templates = Config.load_repo(root).lanes || {}
248
+ fill = lambda do |template|
249
+ template.gsub("{name}", name).gsub("{yyyy}", now.strftime("%Y")).gsub("{mm}", now.strftime("%m"))
250
+ .gsub("{dd}", now.strftime("%d"))
251
+ end
252
+ path = fill.call(templates["worktree_path"] || File.join(WORKTREES_DIR, "{name}"))
253
+ [File.expand_path(path, root), fill.call(templates["branch"] || "#{BRANCH_PREFIX}{name}")]
254
+ rescue Config::ConfigError => e
255
+ raise OptionParser::InvalidArgument, "invalid config #{e.message}"
256
+ end
257
+
179
258
  def create_worktree(root, name)
180
259
  Harnex.ensure_git_excluded(root)
181
- worktree = File.join(root, WORKTREES_DIR, name)
182
- branch = BRANCH_PREFIX + name
260
+ worktree, branch = worktree_layout(root, name)
261
+ _out, _err, valid = git(root, "check-ref-format", "--branch", branch)
262
+ raise OptionParser::InvalidArgument, "lanes.branch gives #{branch.inspect}, which is not a valid branch name" unless valid
263
+
183
264
  _out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
184
265
  if exists || File.exist?(worktree)
185
266
  raise OptionParser::InvalidArgument,
@@ -259,8 +340,12 @@ module Harnex
259
340
  merged
260
341
  end
261
342
 
343
+ # Inside the checkout: "a/b"; beside it: "../x"; anywhere else: absolute.
262
344
  def relative(root, path)
263
- path.to_s.start_with?("#{root}/") ? path.to_s.delete_prefix("#{root}/") : path.to_s
345
+ return path.to_s.delete_prefix("#{root}/") if path.to_s.start_with?("#{root}/")
346
+
347
+ sibling = "../#{File.basename(path.to_s)}"
348
+ File.expand_path(sibling, root.to_s) == File.expand_path(path.to_s) ? sibling : path.to_s
264
349
  end
265
350
 
266
351
  # ---- briefs ------------------------------------------------------------
@@ -419,7 +504,7 @@ module Harnex
419
504
  lane["settled_at"] = now
420
505
  # Kept so a later `harnex result NAME` can show the result again.
421
506
  lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
422
- "outcome_class", "status", "last_error", "end_sha")
507
+ "outcome_class", "status", "last_error", "end_sha", "earlier_replies")
423
508
  save(lane)
424
509
  end
425
510
 
@@ -436,13 +521,79 @@ module Harnex
436
521
 
437
522
  def render(lane, turn, exit_code)
438
523
  lines = []
439
- message = final_message(turn)
524
+ message = with_earlier_replies(final_message(turn), turn)
440
525
  lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
441
526
  lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
442
- lines.concat(details(lane, exit_code).map { |line| " #{line}" })
527
+ lines.concat((interjection_lines(lane) + details(lane, exit_code)).map { |line| " #{line}" })
443
528
  lines.join("\n")
444
529
  end
445
530
 
531
+ # An answered ask prints only its answer, plus what else reached the
532
+ # worker (#92).
533
+ def render_answer(lane, turn)
534
+ text = with_earlier_replies(final_message(turn), turn)
535
+ notes = interjection_lines(lane)
536
+ return text if notes.empty?
537
+
538
+ [text.rstrip, "", "── #{lane['name']}: #{notes.first}", *notes.drop(1).map { |line| " #{line}" }].join("\n")
539
+ end
540
+
541
+ # #92: when a later message reached the worker after it had answered,
542
+ # the turn's result is its answer to that message; the answers it
543
+ # replaced (the original task's report among them) come first.
544
+ def with_earlier_replies(message, turn)
545
+ earlier = Array(turn["earlier_replies"]).map(&:to_s).reject { |text| text.strip.empty? }
546
+ return message if earlier.empty? || message.strip.empty?
547
+
548
+ parts = earlier.map { |text| "[earlier reply this turn, before another message reached the worker]\n#{text.rstrip}" }
549
+ (parts + ["[latest reply]\n#{message.rstrip}"]).join("\n\n")
550
+ end
551
+
552
+ # #92: messages that reached the worker from anyone but the lane's own
553
+ # owner and its `harnex reply` (someone's `harnex send` from another
554
+ # terminal or session), since the lane's latest launch.
555
+ def interjections(lane)
556
+ events(lane).select do |event|
557
+ event["type"] == "send" && event["from"].is_a?(Hash) && after?(event["ts"], lane["launched_at"]) &&
558
+ !own_message?(lane, event["from"])
559
+ end
560
+ end
561
+
562
+ def own_message?(lane, from)
563
+ owner = lane["owner"].to_s
564
+ from["owner"].to_s == owner && (!owner.empty? || from["via"] == "reply")
565
+ end
566
+
567
+ def interjection_lines(lane)
568
+ found = interjections(lane)
569
+ return [] if found.empty?
570
+
571
+ count = found.length == 1 ? "1 message" : "#{found.length} messages"
572
+ ["interjected: #{count} reached the worker from outside this lane " \
573
+ "(the reply above may answer #{found.length == 1 ? 'it' : 'them'}; all: `harnex events --id #{lane['name']}`):"] +
574
+ found.last(5).map { |event| " #{clock(event['ts'])} #{sender_label(event['from'])}: #{preview(event)}" }
575
+ end
576
+
577
+ def sender_label(from)
578
+ who = if from["session"] then "harnex session #{from['session']}"
579
+ elsif from["owner"] then "session #{from['owner'][0, 8]}"
580
+ else "a terminal"
581
+ end
582
+ "from #{who} (harnex #{from['via'] || 'send'})"
583
+ end
584
+
585
+ def preview(event)
586
+ text = event["msg"].to_s.sub(/\A\[harnex [^\]]*\]\s*/, "").gsub(/\s+/, " ").strip
587
+ cut = text.length > 160 || event["msg_truncated"]
588
+ "\"#{text[0, 160]}#{cut ? '…' : ''}\""
589
+ end
590
+
591
+ def clock(ts)
592
+ Time.iso8601(ts.to_s).localtime.strftime("%H:%M:%S")
593
+ rescue ArgumentError
594
+ ts.to_s
595
+ end
596
+
446
597
  def headline(lane, turn, exit_code)
447
598
  model = turn["model"] || lane["model"] || lane["requested_model"]
448
599
  parts = []
@@ -465,6 +616,9 @@ module Harnex
465
616
  dir = lane["worktree"] || lane["cwd"]
466
617
  return nil unless lane["base_sha"] && File.directory?(dir.to_s)
467
618
 
619
+ shas = lane["branch"] ? nil : worker_commits(lane)
620
+ return worker_change_summary(lane, dir, shas) if shas
621
+
468
622
  count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
469
623
  return nil unless ok
470
624
 
@@ -473,6 +627,47 @@ module Harnex
473
627
  stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
474
628
  end
475
629
 
630
+ # #91: a lane working in place shares HEAD with whoever else commits
631
+ # there, so count only the commits the worker made (worker_commits
632
+ # events). nil when the worker's harnex did not track them.
633
+ def worker_commits(lane)
634
+ found = events(lane).select { |event| after?(event["ts"], lane["created_at"]) }
635
+ return nil unless found.any? { |event| event["type"] == "git" && event["tracks_worker_commits"] }
636
+
637
+ found.select { |event| event["type"] == "worker_commits" }.flat_map { |event| Array(event["shas"]) }
638
+ end
639
+
640
+ def worker_change_summary(lane, dir, shas)
641
+ moved, _err, ok = git(dir, "rev-list", "#{lane['base_sha']}..HEAD")
642
+ return nil unless ok
643
+
644
+ history = moved.split
645
+ mine = history & shas
646
+ text = "#{mine.length} commit#{mine.length == 1 ? '' : 's'}"
647
+ stat = mine.empty? ? nil : commits_stat(dir, mine)
648
+ text += ", #{stat}" if stat
649
+ others = history.length - mine.length
650
+ text += " (HEAD also moved #{others} commit#{others == 1 ? '' : 's'} by others)" if others.positive?
651
+ text
652
+ end
653
+
654
+ def commits_stat(dir, shas)
655
+ out, _err, ok = git(dir, "show", "--numstat", "--format=", *shas)
656
+ return nil unless ok
657
+
658
+ files = {}
659
+ added = removed = 0
660
+ out.each_line do |line|
661
+ plus, minus, path = line.chomp.split("\t", 3)
662
+ next unless path
663
+
664
+ files[path] = true
665
+ added += plus.to_i
666
+ removed += minus.to_i
667
+ end
668
+ "#{files.size} file#{files.size == 1 ? '' : 's'} (+#{added} -#{removed})"
669
+ end
670
+
476
671
  def compact_stat(stat)
477
672
  files = stat[/(\d+) files? changed/, 1]
478
673
  added = stat[/(\d+) insertions?/, 1]
data/lib/harnex/mcp.rb CHANGED
@@ -41,6 +41,7 @@ module Harnex
41
41
  "model" => MODEL, "effort" => EFFORT,
42
42
  "fallback_model" => { "type" => "string", "description" => "Ask this model instead if the first is rate limited" },
43
43
  "write" => { "type" => "boolean", "description" => "Let the worker edit files and run commands (default false)" },
44
+ "name" => { "type" => "string", "description" => "Lane name (default: pi-ask-<random>)" },
44
45
  "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
45
46
  } },
46
47
  "annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
@@ -56,7 +57,7 @@ module Harnex
56
57
  "inputSchema" => { "type" => "object", "required" => ["task"], "properties" => {
57
58
  "task" => { "type" => "string", "description" => "Self-contained brief: files, expected behavior, how to verify (e.g. the test command)" },
58
59
  "worktree" => { "type" => "string", "description" => "Lane name; work in a new git worktree on branch harnex/NAME (recommended for edits)" },
59
- "name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: from the task)" },
60
+ "name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: the cwd worktree's name, else from the task)" },
60
61
  "wait" => { "type" => "boolean", "description" => "Wait for the result (default true); false starts the lane and returns at once" },
61
62
  "model" => MODEL, "effort" => EFFORT, "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
62
63
  } },
@@ -250,10 +251,11 @@ module Harnex
250
251
  case tool
251
252
  when "delegate"
252
253
  if args["worktree"].to_s.empty? && args["name"].to_s.empty?
253
- root = Lanes.main_root(work_dir(args)) || work_dir(args)
254
- args["name"] = Lanes.generate_name(root, args["task"] || args["prompt"] || args["brief"])
254
+ dir = work_dir(args)
255
+ args["name"] = Lanes.generate_name(Lanes.main_root(dir) || dir, args["task"] || args["prompt"] || args["brief"], cwd: dir)
255
256
  end
256
257
  when "ask"
258
+ args["id"] = args["name"] unless args["name"].to_s.strip.empty?
257
259
  args["id"] ||= "pi-ask-#{SecureRandom.hex(2)}"
258
260
  end
259
261
  args
@@ -78,7 +78,8 @@ module Harnex
78
78
  text: payload[:text],
79
79
  submit: payload[:submit],
80
80
  enter_only: payload[:enter_only],
81
- force: payload[:force]
81
+ force: payload[:force],
82
+ from: payload[:from]
82
83
  )
83
84
  http_code = result.delete(:http_status) || 200
84
85
  json(client, http_code, result)
@@ -136,6 +137,15 @@ module Harnex
136
137
  context
137
138
  end
138
139
 
140
+ # Who sent a message (#92), as `harnex send` reports it: bounded strings.
141
+ def sender(value)
142
+ return nil unless value.is_a?(Hash)
143
+
144
+ value.slice("via", "session", "owner").filter_map do |key, text|
145
+ [key, text.to_s[0, 120]] unless text.to_s.strip.empty?
146
+ end.to_h
147
+ end
148
+
139
149
  def parse_send_body(headers, body)
140
150
  if headers["content-type"].to_s.include?("application/json")
141
151
  parsed = JSON.parse(body.empty? ? "{}" : body)
@@ -145,7 +155,8 @@ module Harnex
145
155
  text: parsed["text"].to_s,
146
156
  submit: parsed.fetch("submit", true),
147
157
  enter_only: parsed.fetch("enter_only", false),
148
- force: parsed.fetch("force", false)
158
+ force: parsed.fetch("force", false),
159
+ from: sender(parsed["from"])
149
160
  }
150
161
  else
151
162
  {
@@ -32,13 +32,14 @@ module Harnex
32
32
  @thread&.kill
33
33
  end
34
34
 
35
- def enqueue(text:, submit:, enter_only:, force: false)
35
+ def enqueue(text:, submit:, enter_only:, force: false, from: nil)
36
36
  msg = Message.new(
37
37
  id: SecureRandom.hex(8),
38
38
  text: text,
39
39
  submit: submit,
40
40
  enter_only: enter_only,
41
41
  force: force,
42
+ from: from,
42
43
  queued_at: Time.now,
43
44
  status: :queued
44
45
  )
@@ -139,6 +140,7 @@ module Harnex
139
140
  def deliver_now(msg, follow_up: false)
140
141
  options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
141
142
  options[:follow_up] = true if follow_up
143
+ options[:from] = msg.from if msg.from
142
144
  result = @session.inject_via_adapter(**options)
143
145
  msg.status = :delivered
144
146
  msg.delivered_at = Time.now
@@ -1,6 +1,6 @@
1
1
  module Harnex
2
2
  Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error,
3
- :after_seq, :delivery, keyword_init: true) do
3
+ :after_seq, :delivery, :from, keyword_init: true) do
4
4
  def to_h
5
5
  {
6
6
  id: id,
@@ -61,6 +61,7 @@ module Harnex
61
61
  MAX_PENDING_PI_COMMANDS = 256
62
62
  # Inline cap for a turn's final reply; the full text is in its file.
63
63
  FINAL_MESSAGE_LIMIT = 4000
64
+ MAX_EARLIER_REPLIES = 3
64
65
  class EventCounters
65
66
  def initialize
66
67
  @counts = {
@@ -171,6 +172,7 @@ module Harnex
171
172
  @turn_follow_ups_sent = 0
172
173
  @pi_follow_up_pending = 0
173
174
  @turn_final_text = nil
175
+ @turn_earlier_replies = []
174
176
  @completion_notifier = completion_notifier || CompletionNotifier.new(
175
177
  repo_root: repo_root,
176
178
  id: @id,
@@ -190,6 +192,7 @@ module Harnex
190
192
  @observed_commands_mutex = Mutex.new
191
193
  @observed_commands = []
192
194
  @pi_pending_commands = {}
195
+ @pi_git_heads = {}
193
196
  @pi_unobserved_commands = 0
194
197
  # Final gates and the failure flag cover the whole command history; the
195
198
  # retained @observed_commands window is capped at MAX_COMMANDS (#68).
@@ -480,10 +483,12 @@ module Harnex
480
483
  { ok: true, signal: "exit_sequence_sent" }
481
484
  end
482
485
 
483
- def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false)
486
+ # `from` names who sent a message through `harnex send` (#92); the
487
+ # launch prompt has none.
488
+ def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
484
489
  if structured_transport?
485
490
  return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force,
486
- follow_up: follow_up)
491
+ follow_up: follow_up, from: from)
487
492
  end
488
493
 
489
494
  after_seq = current_event_seq
@@ -509,7 +514,7 @@ module Harnex
509
514
  force: payload[:force],
510
515
  after_seq: after_seq
511
516
  )
512
- .tap { emit_send_event(text, force: payload[:force]) }
517
+ .tap { emit_send_event(text, force: payload[:force], from: from) }
513
518
  end
514
519
 
515
520
  # #79: a structured adapter that can queue a message into the running turn
@@ -525,7 +530,7 @@ module Harnex
525
530
  @events_mutex.synchronize { @events_log_seq }
526
531
  end
527
532
 
528
- def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false)
533
+ def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
529
534
  if @runtime_budget&.expired?
530
535
  stop_for_runtime_budget!
531
536
  raise ArgumentError, "session runtime budget is exhausted"
@@ -560,6 +565,8 @@ module Harnex
560
565
  # is reset here. If Pi has in fact gone idle it starts a new run,
561
566
  # whose agent_start begins the next turn.
562
567
  unless queued_into_run
568
+ # A new prompt answers nothing earlier in this turn (#92).
569
+ @pi_last_answer = nil
563
570
  # Reserve work before the blocking RPC. Steering an active run does
564
571
  # not reset its clocks, but a new prompt cannot reuse old proof.
565
572
  @activity.start_turn
@@ -584,7 +591,7 @@ module Harnex
584
591
  end
585
592
  end
586
593
 
587
- emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force])
594
+ emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force], from: from)
588
595
  {
589
596
  ok: true,
590
597
  cli: adapter.key,
@@ -930,6 +937,7 @@ module Harnex
930
937
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
931
938
  schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
932
939
  when "message_start"
940
+ note_pi_user_message(message["message"])
933
941
  @pi_active_message_key = pi_message_key(message["message"])
934
942
  @pi_streamed_text_by_message[@pi_active_message_key] = false
935
943
  when "message_update"
@@ -955,6 +963,7 @@ module Harnex
955
963
  if pi_command_tool?(message)
956
964
  @event_counters.record_item({ "type" => "commandExecution" })
957
965
  start_pi_command(message)
966
+ note_pi_git_head(message)
958
967
  else
959
968
  @event_counters.record_item({ "type" => "dynamicToolCall" })
960
969
  end
@@ -962,7 +971,10 @@ module Harnex
962
971
  "tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
963
972
  )
964
973
  when "tool_execution_end"
965
- finish_pi_command(message) if pi_command_tool?(message)
974
+ if pi_command_tool?(message)
975
+ finish_pi_command(message)
976
+ record_pi_worker_commits(message)
977
+ end
966
978
  tool_name = message["toolName"] || "tool"
967
979
  status = message["isError"] ? "error" : "ok"
968
980
  record_synthesized("tool-result: #{tool_name} (#{status})")
@@ -1198,6 +1210,9 @@ module Harnex
1198
1210
 
1199
1211
  text = pi_extract_message_text(message).to_s
1200
1212
  @turn_final_text = text unless text.strip.empty?
1213
+ # An answer (stopReason "stop"), kept apart from the run outcome that
1214
+ # every turn_start resets (#92).
1215
+ @pi_last_answer = message["stopReason"] == "stop" && !text.strip.empty? ? text : nil
1201
1216
 
1202
1217
  reason = message["stopReason"].to_s
1203
1218
  @pi_last_assistant_stop_reason = reason unless reason.empty?
@@ -1205,6 +1220,49 @@ module Harnex
1205
1220
  @pi_last_assistant_error = error unless error.empty?
1206
1221
  end
1207
1222
 
1223
+ # #92: a message that reaches the worker after it has answered (a
1224
+ # follow-up from the lane's owner or from anyone else) starts a new
1225
+ # answer; keep the one it replaces so the turn's result still has it.
1226
+ def note_pi_user_message(message)
1227
+ return unless message.is_a?(Hash) && message["role"] == "user"
1228
+
1229
+ text = @pi_last_answer.to_s.strip
1230
+ @pi_last_answer = nil
1231
+ return if text.empty? || @turn_earlier_replies.last == text
1232
+
1233
+ @turn_earlier_replies = (@turn_earlier_replies + [text]).last(MAX_EARLIER_REPLIES)
1234
+ end
1235
+
1236
+ # #91: commits the worker made, told apart from HEAD moving under it
1237
+ # (an orchestrator committing in the same checkout): HEAD is read around
1238
+ # each command that runs git, and only commits that appear during one
1239
+ # count as the worker's.
1240
+ def note_pi_git_head(message)
1241
+ args = message["args"]
1242
+ command = args["command"] if args.is_a?(Hash)
1243
+ return unless command.is_a?(String) && command.match?(/\bgit\b/) && @git_start && !@git_start.empty?
1244
+
1245
+ head = observed_git_head
1246
+ @pi_git_heads[message["toolCallId"].to_s] = head if head
1247
+ end
1248
+
1249
+ def record_pi_worker_commits(message)
1250
+ before = @pi_git_heads.delete(message["toolCallId"].to_s)
1251
+ after = before && observed_git_head
1252
+ return if after.nil? || after == before
1253
+
1254
+ shas = Harnex.git_output(git_observation_root, "rev-list", "--max-count=100", "#{before}..#{after}").split
1255
+ emit_event("worker_commits", shas: shas) unless shas.empty?
1256
+ rescue StandardError
1257
+ nil
1258
+ end
1259
+
1260
+ def observed_git_head
1261
+ Harnex.git_output(git_observation_root, "rev-parse", "HEAD")
1262
+ rescue StandardError
1263
+ nil
1264
+ end
1265
+
1208
1266
  def settle_pi_task!
1209
1267
  return if @stop_requested || @pi_transport_failed
1210
1268
 
@@ -1392,6 +1450,8 @@ module Harnex
1392
1450
  @completion_notification_sent = false
1393
1451
  @turn_follow_ups_sent = 0
1394
1452
  @turn_final_text = nil
1453
+ @turn_earlier_replies = []
1454
+ @pi_last_answer = nil
1395
1455
  @completion_turn += 1
1396
1456
  end
1397
1457
  return unless turn
@@ -1451,6 +1511,7 @@ module Harnex
1451
1511
  end
1452
1512
  fields = { final_message: text[0, FINAL_MESSAGE_LIMIT], final_message_path: path }
1453
1513
  fields[:final_message_truncated] = true if text.length > FINAL_MESSAGE_LIMIT
1514
+ fields[:earlier_replies] = @turn_earlier_replies.map { |reply| reply[0, FINAL_MESSAGE_LIMIT] } if @turn_earlier_replies.any?
1454
1515
  fields
1455
1516
  rescue StandardError
1456
1517
  { final_message: text[0, FINAL_MESSAGE_LIMIT] }
@@ -1658,11 +1719,13 @@ module Harnex
1658
1719
  warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
1659
1720
  end
1660
1721
 
1661
- def emit_send_event(text, force:)
1722
+ def emit_send_event(text, force:, from: nil)
1662
1723
  compact = text.to_s
1663
1724
  truncated = compact.length > 200
1664
1725
  preview = truncated ? "#{compact[0, 200]}…" : compact
1665
- emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
1726
+ payload = { msg: preview, msg_truncated: truncated, forced: !!force }
1727
+ payload[:from] = from if from.is_a?(Hash) && !from.empty?
1728
+ emit_event("send", **payload)
1666
1729
  end
1667
1730
 
1668
1731
  def emit_started_event
@@ -1685,7 +1748,7 @@ module Harnex
1685
1748
  )
1686
1749
  return if @git_start.empty?
1687
1750
 
1688
- payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch] }
1751
+ payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch], tracks_worker_commits: true }
1689
1752
  payload[:git_root] = git_observation_root if git_lane?
1690
1753
  emit_event("git", **payload)
1691
1754
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.29.1"
3
- RELEASE_DATE = "2026-10-23"
2
+ VERSION = "0.30.0"
3
+ RELEASE_DATE = "2026-10-25"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.1
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
14
14
  the result back in one call: lanes in git worktrees, follow-ups that keep context,