pi-odd-worktree 1.1.1 → 1.2.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.
package/README.md CHANGED
@@ -34,7 +34,6 @@ interchangeably by `worktree_close`, and by `/warp`.
34
34
  | --- | --- |
35
35
  | Pi (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`) | Peer dependencies at `*`, supplied by the host. The package installs no runtime dependencies. |
36
36
  | Node.js >= 18 and `git` | `engines` declares 18 as the floor, and every repository operation is a git child process. |
37
- | `herdr` — optional | Not required: only `worktree_create` tries a Herdr workspace association, and it falls back to plain `git`. |
38
37
  | `gh`, authenticated (`gh auth login`) | `/issues` only. Without it the command stays registered, says once per session what is missing, and never opens. |
39
38
  | `codegraph` — optional | Keeps each checkout's index fresh: `codegraph init` when a checkout is created, and a background `codegraph sync` after edits and writes. Absent, both stay off for the whole session. |
40
39
  | Gentle Shell — recommended | Supplies the presence and transport records the deletion guard reads to see sessions other than this one. |
@@ -154,6 +153,24 @@ Removal is `git worktree remove <absolute path>`, with `--force` only when the
154
153
  caller asked for it. The branch is kept, and **no Herdr workspace is closed,
155
154
  opened or removed** on any path.
156
155
 
156
+ ### A warp waits for this session's own agent tasks
157
+
158
+ A warp never lands while this session still has running or queued agent tasks.
159
+ The `worktree_warp` tool refuses **before** it queues anything, and `/warp`
160
+ gives the published counters a bounded chance to drain (`ODD_WORKTREE_WARP_BUSY_WAIT_MS`,
161
+ 60 s by default) before it refuses: the session stays where it is and the notice
162
+ says how many tasks are running. The counters are read from Gentle Shell's
163
+ presence header for this session, through the same bounded reader the deletion
164
+ guard uses.
165
+
166
+ That check is **best effort, and it does not see background jobs**:
167
+
168
+ - Agents publish their task counters in the presence header; a heartbeat older
169
+ than 15 s is not evidence, and an absent or unreadable store blocks nothing.
170
+ - `bash_background` and `monitor` jobs live in Gentle Shell's in-memory job
171
+ registry, which publishes nothing durable and is not exposed by the Pi host,
172
+ so no warp can observe them. Confirm a job finished before warping.
173
+
157
174
  ### `/issues` pins the repository
158
175
 
159
176
  The repository identity (`<host>/<owner>/<repo>`) is pinned when the list loads,
@@ -238,7 +255,7 @@ This package is the worktree / ODD / issues surface only: no customize panel, no
238
255
  plugin-tools toggles, no Herdr plugin management, and no worktree↔issue
239
256
  association. There is nothing to configure, and `/warp`, `/odd-list` and
240
257
  `/issues` are always on (the three `/codegraph-*` commands follow the CLI).
241
- Paths, branches and arguments reach `git`, `herdr` and `gh` as fixed argv arrays,
258
+ Paths, branches and arguments reach `git` and `gh` as fixed argv arrays,
242
259
  never as a shell string.
243
260
 
244
261
  ## Tests
@@ -14,8 +14,7 @@
14
14
  // worktree_warp. Create and close prompt for approval when the host offers
15
15
  // dialog UI; list never mutates anything; warp hands the move to /warp.
16
16
  //
17
- // Herdr is optional, never required: every Herdr call is attempted first and
18
- // falls back to plain git, so the package works in Pi alone.
17
+ // Worktree creation and removal use git; no Herdr workspace is opened.
19
18
  //
20
19
  // The file is plain JavaScript with a .ts extension (// @ts-nocheck, no type
21
20
  // annotations), so the test copy-to-.mjs loading pattern keeps working under
@@ -69,54 +68,6 @@ function setWarpTuiLoader(loader) {
69
68
  warpTuiLoader = loader;
70
69
  }
71
70
 
72
- // The herdr binary honors the documented HERDR_BIN_PATH override (tests),
73
- // else `herdr` on PATH.
74
- //
75
- // lib/worktree-git.js `resolveHerdrBinary` is the SINGLE authority and its
76
- // answer is propagated verbatim, including its refusal: under the test harness
77
- // (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders) a value that may be
78
- // the live binary is refused unless the CURRENT test opted in with
79
- // GENTLE_WORKTREE_ALLOW_LIVE_HERDR=1. A `catch {}` around that call used to
80
- // swallow the refusal and then re-apply an inherited HERDR_BIN_PATH here, which
81
- // handed a test process exactly the live binary the authority had just refused
82
- // (a real Herdr workspace, created from a test). No fallback in this file may
83
- // re-decide the binary: the only remaining branch is for a lib that is missing
84
- // entirely, and it fails closed under the harness.
85
- function herdrBinary() {
86
- let git = null;
87
- try {
88
- git = libRequire("worktree-git.js");
89
- } catch {
90
- git = null;
91
- }
92
- if (git && typeof git.resolveHerdrBinary === "function") {
93
- return git.resolveHerdrBinary(process.env);
94
- }
95
- const harness = process.env.GENTLE_WORKTREE_LIB_DIR;
96
- if (typeof harness === "string" && harness.length > 0) {
97
- throw new Error(
98
- "refusing to run live herdr from a test harness: herdr binary cannot be resolved "
99
- + "(lib/worktree-git.js is unavailable, so no binary is injected)",
100
- );
101
- }
102
- const bin = process.env.HERDR_BIN_PATH;
103
- if (typeof bin === "string" && bin.length > 0) return bin;
104
- return "herdr";
105
- }
106
-
107
- // The texts the authority (and the missing-lib branch above) use to refuse.
108
- // Matching on text keeps the boundary between lib and extension small: a
109
- // refusal is a resolved STATE, not an error class shared across it.
110
- const HERDR_REFUSAL_PATTERN = /refusing to run live herdr|herdr binary is not injected/;
111
-
112
- function isHerdrRefusal(err) {
113
- return !!(
114
- err
115
- && typeof err.message === "string"
116
- && HERDR_REFUSAL_PATTERN.test(err.message)
117
- );
118
- }
119
-
120
71
  function toolCwd(ctx) {
121
72
  if (ctx && typeof ctx.cwd === "string" && ctx.cwd.length > 0) return ctx.cwd;
122
73
  return process.cwd();
@@ -543,12 +494,6 @@ function withWorktreeCardRenderer(tool) {
543
494
  // Model text and visual cards are independent presentation layers. Keep
544
495
  // backend helpers unchanged so runtime callers retain their raw contract.
545
496
  //
546
- // The same seam turns a herdr resolution REFUSAL into an error result: the
547
- // guard's refusal (a test harness handing over a possible live binary, or a
548
- // harness with no binary injected) says the resolution failed, not that the
549
- // tool crashed. Letting it escape would be an uncaught throw out of a tool
550
- // handler, so every refusal is answered with its own text and `isError`, and
551
- // every other exception is re-thrown untouched.
552
497
  const execute = tool.execute;
553
498
  let format = null;
554
499
  try {
@@ -559,13 +504,7 @@ function withWorktreeCardRenderer(tool) {
559
504
  }
560
505
  if (typeof execute === "function") {
561
506
  tool.execute = async function (...args) {
562
- let result;
563
- try {
564
- result = await execute.apply(this, args);
565
- } catch (err) {
566
- if (!isHerdrRefusal(err)) throw err;
567
- result = errorResult(`${tool.name}: ${err.message}`, { tool: tool.name, herdrRefused: true });
568
- }
507
+ const result = await execute.apply(this, args);
569
508
  if (!format) return result;
570
509
  try {
571
510
  return format(tool.name, result);
@@ -703,7 +642,7 @@ async function awaitPendingCreationSetups() {
703
642
  }
704
643
  }
705
644
 
706
- // Core of `worktree_create`: validation (S2, S3) then herdr-first creation
645
+ // Core of `worktree_create`: validation (S2, S3) then git creation
707
646
  // with a git fallback. Exported for unit tests; the registered tool calls
708
647
  // it with the live Pi context. It only ever CREATES: the branch plus the
709
648
  // `changes` selection (S3/S4), and no issue or ODD association is verified,
@@ -711,15 +650,6 @@ async function awaitPendingCreationSetups() {
711
650
  async function executeWorktreeCreate(params, ctx, opts) {
712
651
  const git = libRequire("worktree-git.js");
713
652
  const registry = libRequire("worktree-registry.js");
714
- // S53 git-only route (create-from-warp): the wizard creates a plain
715
- // git checkout plus the registry row and nothing else — never a Herdr
716
- // workspace (`herdr worktree create/open` is what materialized the
717
- // stray Herdr worktree the user never asked for). The route reuses
718
- // every validation and the registry machinery below; only the
719
- // backends change. Subprocesses run through the async runner here so
720
- // a hanging child aborts on its bound instead of freezing the
721
- // interactive session on the main thread.
722
- const gitOnly = !!(opts && opts.gitOnly === true);
723
653
  // `backgroundHooks` is the INTERACTIVE create's speed contract: the checkout
724
654
  // plus its registry row are all the warp needs, so the slow half of the hooks
725
655
  // (install, codegraph) is handed to a tracked background task instead of
@@ -1112,24 +1042,12 @@ async function executeWorktreeCreate(params, ctx, opts) {
1112
1042
  // EXISTING branch with `git worktree add <target> <branch>` — no `-b`,
1113
1043
  // no base, the ref never created, rewritten or deleted — and the result
1114
1044
  // reports `branchCreated: false` plus a warning naming the adopted
1115
- // branch. Herdr's `worktree create` cannot adopt an existing branch, so
1116
- // the adopt case goes through git and then associates the Herdr
1117
- // workspace BEST-EFFORT via `herdr worktree open --path <path>
1118
- // --cwd <root>`; the checkout succeeding never depends on herdr.
1119
- // When the ref does not exist, creation keeps the herdr-first path
1120
- // with a `git worktree add -b` fallback and reports `branchCreated:
1121
- // true`. Fixed argv only, values as separate elements (herdr rejects
1122
- // --flag=value). Never --focus, never --trust-repository: trusting a
1123
- // repository is a human decision these tools never take. The base is
1124
- // LOCAL MAIN (S2), resolved in (d1) before anything was written: a session
1125
- // working inside a linked worktree can never seed the new branch with its
1126
- // own unmerged commits, and a repository without a local `main` was already
1127
- // refused there instead of falling back to the invoking HEAD.
1045
+ // branch. New branches start at local main; existing branches are adopted
1046
+ // unchanged. Both tool and panel creation use git only.
1128
1047
  const base = baseInfo !== null ? baseInfo.commit : null;
1129
1048
  let createdBy = null;
1130
1049
  let createError = null;
1131
1050
  let branchCreated = null;
1132
- let herdrAssociated = null;
1133
1051
  const adoptWarnings = [];
1134
1052
  if (branchExists) {
1135
1053
  // Adopt: check out the existing branch at the fresh path. No `-b`
@@ -1142,45 +1060,9 @@ async function executeWorktreeCreate(params, ctx, opts) {
1142
1060
  adoptWarnings.push(
1143
1061
  `adopted existing branch '${branch}' at ${target}: the worktree checks out that branch as-is, the branch ref was left untouched`,
1144
1062
  );
1145
- // Best-effort Herdr workspace association: reported, never fatal.
1146
- // Skipped on the git-only route: that checkout is git plus the
1147
- // registry row by design, so no association is expected.
1148
- if (!gitOnly) {
1149
- try {
1150
- const opened = await runGit(herdrBinary(), git.herdrArgv("open", { cwd: repo.root, path: target }), {
1151
- cwd: repo.root,
1152
- timeoutMs: 60000,
1153
- });
1154
- herdrAssociated = opened.status === 0;
1155
- } catch (err) {
1156
- herdrAssociated = false;
1157
- }
1158
- if (herdrAssociated !== true) {
1159
- adoptWarnings.push(
1160
- `herdr workspace association failed for ${target} ('herdr worktree open' did not succeed): the checkout itself is fine, associate it by hand when needed`,
1161
- );
1162
- }
1163
- }
1164
1063
  } else {
1165
1064
  createError = ((adopted.stderr || adopted.error || "unknown error") + "").trim();
1166
1065
  }
1167
- } else {
1168
- // The git-only route skips the herdr-first attempt outright: no
1169
- // `herdr worktree create` runs, so no Herdr workspace is ever
1170
- // materialized from the wizard. The tool path keeps herdr-first
1171
- // with the git fallback below, byte-identical.
1172
- let herdrRes = null;
1173
- if (!gitOnly) {
1174
- const createParams = { cwd: repo.root, branch, path: target };
1175
- if (base) createParams.base = base;
1176
- herdrRes = await runGit(herdrBinary(), git.herdrArgv("create", createParams), {
1177
- cwd: repo.root,
1178
- timeoutMs: 120000,
1179
- });
1180
- }
1181
- if (herdrRes !== null && herdrRes.status === 0) {
1182
- createdBy = "herdr";
1183
- branchCreated = true;
1184
1066
  } else {
1185
1067
  const fallbackArgv = ["worktree", "add", "-b", branch, target];
1186
1068
  if (base) fallbackArgv.push(base);
@@ -1212,10 +1094,9 @@ async function executeWorktreeCreate(params, ctx, opts) {
1212
1094
  }
1213
1095
  }
1214
1096
  }
1215
- }
1216
1097
  if (!createdBy) {
1217
1098
  return errorResult(
1218
- `failed to create worktree for branch '${branch}': ${createError || "herdr and git both failed"}`,
1099
+ `failed to create worktree for branch '${branch}': ${createError || "git failed"}`,
1219
1100
  { branch, path: target, pruned: prunedStale },
1220
1101
  );
1221
1102
  }
@@ -1519,11 +1400,8 @@ async function executeWorktreeCreate(params, ctx, opts) {
1519
1400
  // S6: the ONE CodeGraph fact of this create, read from the hooks that just
1520
1401
  // ran (never re-run, never inferred).
1521
1402
  const codegraph = codegraphStatus(hooks);
1522
- // S20 adoption reporting: whether the branch was created or adopted,
1523
- // and (adopt case only) whether the best-effort Herdr workspace
1524
- // association happened. The checkout succeeding never depends on herdr.
1403
+ // Report whether the branch was created or adopted.
1525
1404
  const adoptionDetails = { branchCreated };
1526
- if (herdrAssociated !== null) adoptionDetails.herdrAssociated = herdrAssociated;
1527
1405
  // (l) T41/S50 changes registration: register the checkout just
1528
1406
  // created on the CURRENT session through gentle-shell's
1529
1407
  // `session_worktree_register` host tool, but ONLY when that tool is
@@ -3787,6 +3665,17 @@ async function executeWorktreeWarpTool(params, ctx, opts) {
3787
3665
  // `fromName`/`name`/`path`/`branch`), so the card falls back to that line
3788
3666
  // instead of painting a move that has not happened. The one visible "moved"
3789
3667
  // card comes from the switched-to session once the move is real.
3668
+ // S4: this session's own background work is a refusal, not a queue. The
3669
+ // command that performs the move waits for idle — and a session with running
3670
+ // agent tasks IS idle — so queueing here would move the session out from
3671
+ // under work that is still running. Nothing is queued when this refuses.
3672
+ const toolBusy = await warpSessionActivity(ctx);
3673
+ if (toolBusy && toolBusy.busy === true) {
3674
+ return errorResult(`worktree warp: ${warpBusyText(toolBusy)}`, {
3675
+ tool: "worktree_warp", name: displayName, path: targetCwd, branch: displayBranch,
3676
+ kind: resolved.kind, busy: true,
3677
+ });
3678
+ }
3790
3679
  const handoffDispatched = dispatchWarpHandoff(prepareWarpHandoff({
3791
3680
  name: displayName,
3792
3681
  target: targetCwd,
@@ -4203,6 +4092,62 @@ async function sessionManagerClassFor(ctx) {
4203
4092
  return null;
4204
4093
  }
4205
4094
 
4095
+ // S4: THIS session's own agent-task counters, read from the presence store the
4096
+ // close guard already consumes (lib/worktree-warp-busy.js). Best effort: an
4097
+ // absent module or store answers null and blocks nothing. Background jobs are
4098
+ // NOT covered — gentle-shell keeps them in memory and publishes no durable
4099
+ // evidence — so nothing here claims to see one.
4100
+ async function warpSessionActivity(ctx) {
4101
+ let lib = null;
4102
+ try {
4103
+ lib = libRequire("worktree-warp-busy.js");
4104
+ } catch {
4105
+ return null;
4106
+ }
4107
+ if (!lib || typeof lib.sessionActivity !== "function") return null;
4108
+ const sessionId = closeSessionString(ctx, "getSessionId");
4109
+ try {
4110
+ return await lib.sessionActivity({ sessionId });
4111
+ } catch {
4112
+ return null;
4113
+ }
4114
+ }
4115
+
4116
+ // The one refusal sentence, from the same module that produced the counters. A
4117
+ // missing module falls back to a shorter sentence that still names agent tasks
4118
+ // only.
4119
+ function warpBusyText(activity) {
4120
+ try {
4121
+ const lib = libRequire("worktree-warp-busy.js");
4122
+ if (lib && typeof lib.busyText === "function") return lib.busyText(activity);
4123
+ } catch {
4124
+ // fall through to the fixed sentence
4125
+ }
4126
+ return "this session still has running agent tasks: the warp waits for them to finish (as of the last published heartbeat); background jobs are not visible here, so confirm they finished too";
4127
+ }
4128
+
4129
+ // The COMMAND front's bounded wait: a warp lands after this turn settles, so it
4130
+ // gives the published counters a chance to drain before it refuses. Null when
4131
+ // the module or the session identity is unavailable (best effort, blocks
4132
+ // nothing).
4133
+ async function warpWaitForIdleSession(ctx) {
4134
+ let lib = null;
4135
+ try {
4136
+ lib = libRequire("worktree-warp-busy.js");
4137
+ } catch {
4138
+ return null;
4139
+ }
4140
+ if (!lib || typeof lib.waitForSessionActivity !== "function") return null;
4141
+ const sessionId = closeSessionString(ctx, "getSessionId");
4142
+ try {
4143
+ // The budget travels through the environment so a session can bound its own
4144
+ // wait (ODD_WORKTREE_WARP_BUSY_WAIT_MS); the default keeps a warp short.
4145
+ return await lib.waitForSessionActivity({ sessionId, env: process.env });
4146
+ } catch {
4147
+ return null;
4148
+ }
4149
+ }
4150
+
4206
4151
  function warpFail(text, details) {
4207
4152
  return { text, details: details || {}, isError: true };
4208
4153
  }
@@ -4545,6 +4490,17 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
4545
4490
  );
4546
4491
  }
4547
4492
  await ctx.waitForIdle();
4493
+ // S4: a session with running agent tasks is IDLE for the host, so the wait
4494
+ // above does not wait for that work. This front gives the published counters
4495
+ // a bounded chance to drain and then refuses instead of moving the session
4496
+ // out from under a task that is still running. A store that is absent or
4497
+ // unreadable blocks nothing.
4498
+ const busyWait = await warpWaitForIdleSession(ctx);
4499
+ if (busyWait && busyWait.activity && busyWait.activity.busy === true) {
4500
+ return warpFail(`worktree warp: ${warpBusyText(busyWait.activity)}`, {
4501
+ name, alreadyThere: false, cancelled: false, busy: true,
4502
+ });
4503
+ }
4548
4504
  const fromCwd = typeof ctx.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : process.cwd();
4549
4505
  // S21: the same awaited preflight reads as the tool, so a slow repository
4550
4506
  // never freezes the session that is being moved.
@@ -5975,8 +5931,8 @@ async function warpCreateAndEnter(ctx, git, notify) {
5975
5931
  return { closed: true, created: false, cancelled: true };
5976
5932
  }
5977
5933
  createParams.changes = pending.copy ? 'all' : 'none';
5978
- // S53: the git-only route — a plain git checkout plus the registry row,
5979
- // never a Herdr workspace — reusing the same creation path (validations,
5934
+ // S53: the panel's create — a plain git checkout plus the registry row —
5935
+ // reusing the same creation path (validations,
5980
5936
  // refusals, registration), which never prompts. The layout is
5981
5937
  // principal-anchored (S56): the new checkout lands beside the PRINCIPAL
5982
5938
  // checkout even when the panel runs inside a linked worktree, while the
@@ -5989,7 +5945,6 @@ async function warpCreateAndEnter(ctx, git, notify) {
5989
5945
  // can move, or the human would switch into a checkout that is missing the
5990
5946
  // work they just asked to carry.
5991
5947
  const created = await executeWorktreeCreate(createParams, ctx, {
5992
- gitOnly: true,
5993
5948
  principalLayout: true,
5994
5949
  backgroundHooks: true,
5995
5950
  ...(typeof notify === 'function' ? { notify } : {}),
@@ -3,8 +3,7 @@
3
3
  //
4
4
  // Every external process is spawned with a fixed argv array — no shell
5
5
  // string is ever built, so branch names and paths cannot inject commands.
6
- // `listWorktrees` parses `git worktree list --porcelain -z` itself instead
7
- // of trusting Herdr JSON output.
6
+ // `listWorktrees` parses `git worktree list --porcelain -z` itself.
8
7
 
9
8
  const { spawnSync } = require('node:child_process');
10
9
  const fs = require('node:fs');
@@ -564,193 +563,7 @@ function matchesAnyCheckout(identity, resolved, entries) {
564
563
  return false;
565
564
  }
566
565
 
567
- // Closed union of herdr operations with a per-operation flag allowlist,
568
- // mirroring the installed CLI surface (herdr 0.9.4, verified read-only
569
- // against `herdr worktree list|create|open --help`, `herdr pane list
570
- // --help` and `herdr agent start|get|list --help`: every flag below
571
- // exists there with the shape used here). Every value travels as its own
572
- // argv element because herdr rejects `--flag=value`. Unknown operations
573
- // or flags throw. `--trust-repository` is deliberately absent everywhere:
574
- // trusting a repository is a human decision these tools never take.
575
- // `--focus` stays opt-in for the worktree create/open calls that predate
576
- // the session tool; the session tool itself only ever passes `--no-focus`.
577
- const HERDR_ALLOWLIST = {
578
- list: ['cwd'],
579
- create: ['cwd', 'branch', 'base', 'path', 'label', 'focus'],
580
- open: ['cwd', 'path', 'branch', 'noFocus', 'focus'],
581
- remove: ['workspace', 'force'],
582
- 'pane-list': ['workspace'],
583
- 'agent-start': ['name', 'kind', 'pane', 'timeout', 'agentArgs'],
584
- 'agent-get': ['target'],
585
- 'agent-list': [],
586
- };
587
-
588
- const HERDR_FLAG = {
589
- cwd: '--cwd',
590
- workspace: '--workspace',
591
- branch: '--branch',
592
- base: '--base',
593
- path: '--path',
594
- label: '--label',
595
- focus: '--focus',
596
- noFocus: '--no-focus',
597
- force: '--force',
598
- kind: '--kind',
599
- pane: '--pane',
600
- timeout: '--timeout',
601
- };
602
566
 
603
- // Build the fixed argv for a herdr operation. Worktree operations produce
604
- // `herdr worktree <op> ...`; the session composition additionally needs
605
- // `herdr pane list`, `herdr agent start <NAME> ... -- [AGENT_ARG]...`,
606
- // `herdr agent get <target>` and `herdr agent list`, each with its own
607
- // fixed shape below so no caller hand-builds herdr argv.
608
- function herdrArgv(operation, params = {}) {
609
- const allowed = HERDR_ALLOWLIST[operation];
610
- if (!allowed) throw new Error(`unknown herdr operation: ${operation}`);
611
- for (const key of Object.keys(params)) {
612
- if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
613
- }
614
- // `herdr agent start <NAME> --kind <KIND> --pane <ID> --timeout <MS>
615
- // -- [AGENT_ARG]...`: the agent name is positional and always first,
616
- // the Pi argv travels after a `--` separator. Fixed order, values as
617
- // separate elements, never `--flag=value`.
618
- if (operation === 'agent-start') {
619
- const { name, kind, pane, timeout, agentArgs } = params;
620
- if (typeof name !== 'string' || name === '') throw new Error('invalid value for herdr agent-start name');
621
- for (const [key, value] of [['kind', kind], ['pane', pane], ['timeout', timeout]]) {
622
- if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr agent-start ${key}`);
623
- }
624
- if (!Array.isArray(agentArgs) || agentArgs.some((entry) => typeof entry !== 'string')) {
625
- throw new Error('invalid value for herdr agent-start agentArgs');
626
- }
627
- return ['agent', 'start', name,
628
- '--kind', kind, '--pane', pane, '--timeout', timeout,
629
- '--', ...agentArgs];
630
- }
631
- // `herdr agent get <target>`: the target is positional.
632
- if (operation === 'agent-get') {
633
- const { target } = params;
634
- if (typeof target !== 'string' || target === '') throw new Error('invalid value for herdr agent-get target');
635
- return ['agent', 'get', target];
636
- }
637
- // `herdr agent list` takes no flags.
638
- if (operation === 'agent-list') return ['agent', 'list'];
639
- // `herdr pane list [--workspace <ID>]`.
640
- if (operation === 'pane-list') {
641
- const argv = ['pane', 'list'];
642
- if (params.workspace !== undefined) {
643
- if (typeof params.workspace !== 'string' || params.workspace === '') {
644
- throw new Error('invalid value for herdr pane-list workspace');
645
- }
646
- argv.push('--workspace', params.workspace);
647
- }
648
- return argv;
649
- }
650
- const argv = ['worktree', operation];
651
- for (const key of Object.keys(params)) {
652
- if (!allowed.includes(key)) throw new Error(`flag not allowed for herdr ${operation}: ${key}`);
653
- const value = params[key];
654
- if (typeof value === 'boolean') {
655
- if (value) argv.push(HERDR_FLAG[key]);
656
- continue;
657
- }
658
- if (typeof value !== 'string' || value === '') throw new Error(`invalid value for herdr ${operation} ${key}`);
659
- argv.push(HERDR_FLAG[key], value);
660
- }
661
- return argv;
662
- }
663
-
664
- // Where the herdr binary comes from. Production honors the documented
665
- // HERDR_BIN_PATH override (tests), else `herdr` on PATH — the same
666
- // convention lib/gentle-diagnostics.js uses. Production (no harness
667
- // marker) keeps that historical behavior untouched.
668
- //
669
- // The test harness (GENTLE_WORKTREE_LIB_DIR, set only by the test loaders
670
- // that import the extension copy) is resolved FIRST, before the override is
671
- // trusted: an exported HERDR_BIN_PATH is inherited by every test process, so
672
- // honoring it first let a test run spawn the developer's LIVE herdr and
673
- // create real Herdr workspaces. Under the harness, therefore:
674
- //
675
- // - no binary at all still fails closed with the same refusal as before;
676
- // - a binary is honored only when the path itself proves it cannot be the
677
- // live one (`isProvablyNotLiveHerdr`, which decides from the filesystem and
678
- // never executes anything);
679
- // - a possible live herdr is refused unless the CURRENT test opts in with the
680
- // dedicated flag below. The flag is the test's own declaration, so it must
681
- // be set deliberately rather than inherited along with the path.
682
- const LIVE_HERDR_OPT_IN = 'GENTLE_WORKTREE_ALLOW_LIVE_HERDR';
683
-
684
- function realPathOrNull(target) {
685
- try {
686
- return fs.realpathSync(target);
687
- } catch {
688
- return null;
689
- }
690
- }
691
-
692
- function isInsideDir(candidate, dir) {
693
- if (candidate === dir) return true;
694
- return candidate.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
695
- }
696
-
697
- // The scratch locations this repository's tests build their stub binaries in.
698
- // os.tmpdir() is the convention every fixture follows (`fs.mkdtempSync(
699
- // path.join(os.tmpdir(), ...))`), and no live Herdr install lives there.
700
- function testScratchRoots() {
701
- const roots = [];
702
- for (const candidate of [os.tmpdir(), path.resolve(os.tmpdir())]) {
703
- const real = realPathOrNull(candidate) || candidate;
704
- if (!roots.includes(real)) roots.push(real);
705
- }
706
- return roots;
707
- }
708
-
709
- // Can this path be PROVEN not to be a live herdr? Only filesystem facts are
710
- // used — the binary is never executed and nothing is spawned, so the answer
711
- // costs one realpath plus one stat and cannot reach a Herdr session.
712
- //
713
- // Proven not live: an absolute path that does not exist (a spawn of it fails),
714
- // that is not a regular file, that is not executable (a spawn of it fails),
715
- // or whose realpath is inside the test scratch directory. Everything else —
716
- // a relative or bare value (PATH/cwd resolved) and an existing executable
717
- // outside scratch — could be the live binary and is left unproven.
718
- function isProvablyNotLiveHerdr(bin) {
719
- if (typeof bin !== 'string' || !path.isAbsolute(bin)) return false;
720
- const real = realPathOrNull(bin);
721
- if (real === null) return true;
722
- let stat;
723
- try {
724
- stat = fs.statSync(real);
725
- } catch {
726
- return true;
727
- }
728
- if (!stat.isFile()) return true;
729
- try {
730
- fs.accessSync(real, fs.constants.X_OK);
731
- } catch {
732
- return true;
733
- }
734
- return testScratchRoots().some((root) => isInsideDir(real, root));
735
- }
736
-
737
- function resolveHerdrBinary(env = {}) {
738
- const source = env || {};
739
- const bin = source.HERDR_BIN_PATH;
740
- const injected = typeof bin === 'string' && bin.length > 0;
741
- const harness = source.GENTLE_WORKTREE_LIB_DIR;
742
- const inHarness = typeof harness === 'string' && harness.length > 0;
743
- if (!inHarness) return injected ? bin : 'herdr';
744
- if (source[LIVE_HERDR_OPT_IN] === '1') return injected ? bin : 'herdr';
745
- if (!injected) {
746
- throw new Error('herdr binary is not injected: set HERDR_BIN_PATH in tests (refusing to run live herdr)');
747
- }
748
- if (isProvablyNotLiveHerdr(bin)) return bin;
749
- throw new Error(
750
- `refusing to run live herdr from a test harness: HERDR_BIN_PATH=${bin} can execute a live herdr; `
751
- + `inject a scratch stub inside the OS temp directory, or set ${LIVE_HERDR_OPT_IN}=1 in the test that needs a live session`,
752
- );
753
- }
754
567
 
755
568
  // ---------------------------------------------------------------------
756
569
  // Commits ahead of / behind a base branch (warp-popup W1).
@@ -1043,6 +856,4 @@ module.exports = {
1043
856
  listWorktreesAsync,
1044
857
  isPathInAnyCheckout,
1045
858
  isPathInAnyCheckoutAsync,
1046
- herdrArgv,
1047
- resolveHerdrBinary,
1048
859
  };
@@ -8,7 +8,7 @@
8
8
  // (b) dependency install by lockfile-detected stack,
9
9
  // (c) `codegraph init` when the CLI is available.
10
10
  //
11
- // Both `worktree_upsert` (sync runner) and the wizard's gitOnly path (async
11
+ // Both `worktree_upsert` (sync runner) and the panel create path (async
12
12
  // twin) reach these hooks, so the link policy is applied by ONE shared
13
13
  // implementation in `lib/worktree-symlinks.js` and both creation routes
14
14
  // behave identically.
@@ -394,7 +394,7 @@ async function runCreationHooksAsync(opts = {}) {
394
394
 
395
395
  // (a2) Link git-ignored principal entries: the async twin of the sync
396
396
  // step, sharing the one classifier in lib/worktree-symlinks.js so the tool
397
- // path and the gitOnly/wizard path cannot disagree. The git reads and the
397
+ // path and the panel create path cannot disagree. The git reads and the
398
398
  // link writes are awaited, so the event loop keeps running.
399
399
  //
400
400
  // Policy (round-3 S3/L4): `.agents`, `.pi`, `.claude` and `odd` become
@@ -362,6 +362,14 @@ async function readGentlePresence(options = {}) {
362
362
  incarnation: header.incarnation,
363
363
  label: header.label,
364
364
  heartbeat: header.heartbeat,
365
+ // Published task counters, validated above and passed through
366
+ // unchanged: the warp guard reads this session's own workload.
367
+ counts: {
368
+ running: header.counts.running,
369
+ queued: header.counts.queued,
370
+ waiting: header.counts.waiting,
371
+ finished: header.counts.finished,
372
+ },
365
373
  recent: now >= header.heartbeat && now - header.heartbeat <= PRESENCE_FRESH_MS,
366
374
  workspace,
367
375
  unknown: workspace === null,