pi-odd-worktree 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -3
- package/extensions/odd-worktree-tools.ts +213 -5
- package/lib/codegraph-commands.js +165 -0
- package/lib/codegraph-session.js +438 -0
- package/lib/odd-worktrees-prompt.js +109 -0
- package/lib/worktree-hooks.js +23 -4
- package/lib/worktree-warp-sessions.js +28 -0
- package/package.json +1 -1
- package/tools/codegraph-commands.test.js +371 -0
- package/tools/codegraph-session.test.js +457 -0
- package/tools/odd-worktrees-prompt.test.js +170 -0
- package/tools/package-surface.test.js +13 -4
- package/tools/warp-empty-session.test.js +495 -0
package/README.md
CHANGED
|
@@ -15,9 +15,12 @@ alone; Herdr is optional.
|
|
|
15
15
|
| `worktree_create` | tool | Creates a branch and its checkout from the **local** `main`, registers it, runs the creation hooks, and copies the pending changes you name. |
|
|
16
16
|
| `worktree_warp` | tool | Queues the session move to another checkout as a `/warp` handoff; the command performs the move when the turn ends. |
|
|
17
17
|
| `worktree_close` | tool | Removes a registered checkout with `git worktree remove`, keeping the branch, and refuses while a live Pi session is working in it. |
|
|
18
|
-
| `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. |
|
|
18
|
+
| `/warp` | panel | Moves this session to another checkout: no argument opens the selector (list, create, delete, extended list); `/warp <name>` jumps straight there. A session with no conversation yet changes checkout **silently** — no fork, no `Warp · moved` card, nothing to carry — and warping back from it is the same empty case. |
|
|
19
19
|
| `/odd-list` | panel | Two-pane reader of this checkout's `odd/tasks/*.md`: list on the left, styled markdown on the right. |
|
|
20
20
|
| `/issues` | panel | Full-screen reader of this checkout's repository issues — My issues / Assigned / Involved / All — with preview, comments, labels, assignees and close, gated on your repository role. |
|
|
21
|
+
| `/codegraph-hook` | command | **Only when the CodeGraph CLI is installed.** Turns the edit/write sync on or off for the session. |
|
|
22
|
+
| `/codegraph-init` | command | **Only when the CLI is installed.** Runs `codegraph init` at the session's current directory and says what happened: `initialized <path> — 1 file, 3 nodes, 3 edges`, or `already initialized at <path> — nothing to do`. |
|
|
23
|
+
| `/codegraph-sync` | command | **Only when the CLI is installed.** Runs `codegraph sync` at the session's current directory and says what happened: `already up to date — nothing to sync`, `synced 3 changed files — 12 nodes`, or `no index in this checkout — run /codegraph-init first`. |
|
|
21
24
|
|
|
22
25
|
A checkout's **name** is the slug of its branch, and its path is
|
|
23
26
|
`<repo-parent>/<repo-name>-worktrees/<slug>`: the slug lowercases the branch and
|
|
@@ -33,6 +36,7 @@ interchangeably by `worktree_close`, and by `/warp`.
|
|
|
33
36
|
| Node.js >= 18 and `git` | `engines` declares 18 as the floor, and every repository operation is a git child process. |
|
|
34
37
|
| `herdr` — optional | Not required: only `worktree_create` tries a Herdr workspace association, and it falls back to plain `git`. |
|
|
35
38
|
| `gh`, authenticated (`gh auth login`) | `/issues` only. Without it the command stays registered, says once per session what is missing, and never opens. |
|
|
39
|
+
| `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. |
|
|
36
40
|
| Gentle Shell — recommended | Supplies the presence and transport records the deletion guard reads to see sessions other than this one. |
|
|
37
41
|
|
|
38
42
|
```bash
|
|
@@ -160,18 +164,80 @@ the checkout's remote moves while the reader is open, mutations refuse with
|
|
|
160
164
|
…); reopen /issues`, and the reads keep the displayed repository. An issue
|
|
161
165
|
number on screen can never be sent to another repository.
|
|
162
166
|
|
|
167
|
+
### CodeGraph stays fresh, when the CLI is installed
|
|
168
|
+
|
|
169
|
+
The probe for `codegraph` on `PATH` runs **once per session** and is cached for
|
|
170
|
+
everything below. Install the CLI mid-session and nothing changes until the next
|
|
171
|
+
start, unless you set `CODEGRAPH_FORCE=1`, which answers "present" without
|
|
172
|
+
probing.
|
|
173
|
+
|
|
174
|
+
- **On creation**, `worktree_create` runs `codegraph init` inside the new
|
|
175
|
+
checkout, so it starts with its own index. A session where the gate answered
|
|
176
|
+
false reports that step as `skipped` and names the gate as the reason; it never
|
|
177
|
+
silently pretends to have indexed anything.
|
|
178
|
+
- **On edits**, a successful `edit` or `write` schedules `codegraph sync` for the
|
|
179
|
+
git toplevel of the file touched, so a worktree syncs that worktree. The sync is
|
|
180
|
+
detached: the tool result never waits for it, and a failure never reaches you.
|
|
181
|
+
- **Debounced and coalesced.** Rapid edits collapse into one sync after ~2s, and
|
|
182
|
+
an edit that lands while a sync runs starts exactly one more when it ends —
|
|
183
|
+
never a queue.
|
|
184
|
+
- **One sync at a time across sessions**, guarded by
|
|
185
|
+
`<root>/.codegraph/pi-sync.lock` holding `{pid, startedAt}`. A lock held by a
|
|
186
|
+
live process means skip (the other session's sync already covers that tree),
|
|
187
|
+
with one bounded retry so the edit that raced it still lands. A dead pid or an
|
|
188
|
+
expired lock is taken over.
|
|
189
|
+
- **Skipped when it would be wasted**: no `.codegraph/` in that checkout, or a
|
|
190
|
+
live CodeGraph daemon (`<root>/.codegraph/daemon.pid`, either the JSON record
|
|
191
|
+
1.6 writes or the older bare pid), whose own watcher is already syncing that
|
|
192
|
+
tree.
|
|
193
|
+
- **Bounded**: a sync that never finishes is terminated after two minutes so a
|
|
194
|
+
checkout can never be wedged for the rest of the session.
|
|
195
|
+
|
|
196
|
+
Three commands exist **only when the CLI is present**, and they do not appear at
|
|
197
|
+
all otherwise:
|
|
198
|
+
|
|
199
|
+
| Command | What it does |
|
|
200
|
+
| --- | --- |
|
|
201
|
+
| `/codegraph-hook` | Turns the edit/write sync on or off for the whole process. No argument toggles; `on`, `off` and `status` say it explicitly. It starts enabled. |
|
|
202
|
+
| `/codegraph-init` | Runs `codegraph init` at the session's current directory. |
|
|
203
|
+
| `/codegraph-sync` | Runs `codegraph sync` at the session's current directory. |
|
|
204
|
+
|
|
205
|
+
The toggle lasts for the session — this package writes nothing to user
|
|
206
|
+
configuration — so `CODEGRAPH_HOOK=0` is how you start a session with it off.
|
|
207
|
+
|
|
208
|
+
### The `## ODD worktrees` block in the system prompt
|
|
209
|
+
|
|
210
|
+
The primary session's system prompt gains a short section telling the agent where
|
|
211
|
+
worktrees live, to resume an ODD in its assigned one, and — when `codegraph` is
|
|
212
|
+
present — that creation indexes the checkout and edits sync it.
|
|
213
|
+
|
|
214
|
+
It is **appended**, never substituted: the block rides
|
|
215
|
+
`systemPromptOptions.appendSystemPrompt`, so whatever Gentle Shell (or any other
|
|
216
|
+
extension) put there is preserved verbatim and our text lands after it. Two
|
|
217
|
+
variants exist, with and without the CodeGraph sentences, chosen by the same
|
|
218
|
+
session gate. Child and named-agent sessions get nothing, because their own
|
|
219
|
+
instructions are already the bounded task they received. `ODD_WORKTREE_PROMPT=0`
|
|
220
|
+
suppresses the block.
|
|
221
|
+
|
|
222
|
+
One limit, stated plainly: a string has no "must be last" flag, so the block's
|
|
223
|
+
position follows extension load order. It can sit before a later extension's
|
|
224
|
+
short hint; it can never land inside or in place of another extension's text.
|
|
225
|
+
|
|
163
226
|
### Where state lives
|
|
164
227
|
|
|
165
228
|
The worktree registry is per repository at `<git-common-dir>/pi-worktrees.json`,
|
|
166
229
|
so every worktree of the same clone sees the same rows. Session names derived
|
|
167
230
|
from an ODD write, and the warp's fork/resume bookkeeping, travel in the Pi
|
|
168
|
-
session files the host already owns.
|
|
231
|
+
session files the host already owns. The only other file this package writes is
|
|
232
|
+
the short-lived `<root>/.codegraph/pi-sync.lock`. Nothing is written to user
|
|
233
|
+
configuration.
|
|
169
234
|
|
|
170
235
|
### Deliberately absent
|
|
171
236
|
|
|
172
237
|
This package is the worktree / ODD / issues surface only: no customize panel, no
|
|
173
238
|
plugin-tools toggles, no Herdr plugin management, and no worktree↔issue
|
|
174
|
-
association. There is nothing to configure, and
|
|
239
|
+
association. There is nothing to configure, and `/warp`, `/odd-list` and
|
|
240
|
+
`/issues` are always on (the three `/codegraph-*` commands follow the CLI).
|
|
175
241
|
Paths, branches and arguments reach `git`, `herdr` and `gh` as fixed argv arrays,
|
|
176
242
|
never as a shell string.
|
|
177
243
|
|
|
@@ -4244,6 +4244,16 @@ function warpNoticeText(targetCwd, branch) {
|
|
|
4244
4244
|
return `Worktree warp: now in ${to} (branch '${cleanBranch}').`;
|
|
4245
4245
|
}
|
|
4246
4246
|
|
|
4247
|
+
// S1 (warp-empty-session): what an EMPTY session's warp reports. It names
|
|
4248
|
+
// WHERE the session now is and never a move: nothing was carried, so the
|
|
4249
|
+
// `moved:`/`to:` compact — and the card that accompanies it — would claim a
|
|
4250
|
+
// transition that did not happen.
|
|
4251
|
+
function warpEmptyText(name, targetCwd) {
|
|
4252
|
+
const toName = warpClean(name, 'worktree');
|
|
4253
|
+
const to = warpClean(targetCwd, '(unknown checkout)');
|
|
4254
|
+
return `worktree warp: ${toName} (empty session, nothing to carry; now at ${to})`;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4247
4257
|
// Deepest live checkout containing a cwd, by resolved identity — the one
|
|
4248
4258
|
// rule the listing, the selector title and the already-there check share.
|
|
4249
4259
|
// `extraIds` lets the already-there check keep its target-cwd candidate
|
|
@@ -4813,6 +4823,18 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
4813
4823
|
}
|
|
4814
4824
|
if (handoff) await warpConfirm(r, warpHandoffNotice, true);
|
|
4815
4825
|
};
|
|
4826
|
+
// S1/S3 (warp-empty-session): the switched-to session of an EMPTY warp is
|
|
4827
|
+
// told nothing — no card, and no handoff continuation either, because the
|
|
4828
|
+
// session carries no task yet (the already-there no-op answers a handoff
|
|
4829
|
+
// with text only, for the same reason). The status line stays: it says
|
|
4830
|
+
// WHERE the session is, not what it carried.
|
|
4831
|
+
const warpSilentWithSession = async (r) => {
|
|
4832
|
+
try {
|
|
4833
|
+
r.ui.setStatus("worktree", `${displayName} (${targetCwd})`);
|
|
4834
|
+
} catch {
|
|
4835
|
+
// Status is cosmetic; the silence is the point.
|
|
4836
|
+
}
|
|
4837
|
+
};
|
|
4816
4838
|
// S43 records. The POINT of a session is its ACTIVE branch (the live
|
|
4817
4839
|
// leaf's parentId path, never all append-only entries) filtered to
|
|
4818
4840
|
// entries that are NOT our own bookkeeping — the custom marker, the
|
|
@@ -4855,6 +4877,16 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
4855
4877
|
const currentPoint = warpSessions.sessionPoint(
|
|
4856
4878
|
currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
|
|
4857
4879
|
);
|
|
4880
|
+
// S1/S5 (warp-empty-session): an EMPTY session is one whose active branch
|
|
4881
|
+
// carries no conversation — no `message` entry with role user or assistant
|
|
4882
|
+
// outside the warp bookkeeping, so a rename, the session-worktree
|
|
4883
|
+
// registration or a transition marker never counts as content. It has
|
|
4884
|
+
// nothing to carry, so its warp changes PATH instead of moving work: never
|
|
4885
|
+
// a fork (the host refuses a conversation-less source), never a card, never
|
|
4886
|
+
// a "moved:" claim.
|
|
4887
|
+
const sessionEmpty = !warpSessions.hasConversation(
|
|
4888
|
+
currentEntriesList, typeof currentLeaf === "string" ? currentLeaf : undefined,
|
|
4889
|
+
);
|
|
4858
4890
|
const warpFileOpen = options && typeof options.warpSessionFileOpen === "function"
|
|
4859
4891
|
? options.warpSessionFileOpen
|
|
4860
4892
|
: undefined;
|
|
@@ -5023,7 +5055,13 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5023
5055
|
// cached fingerprint. An unreadable destination never resumes.
|
|
5024
5056
|
// Never throws: any comparison failure reads as "fork".
|
|
5025
5057
|
const freshDestPoint = warpFreshPoint(recorded.sessionFile);
|
|
5026
|
-
|
|
5058
|
+
// An empty source has no point to compare (its own point is empty
|
|
5059
|
+
// by definition), so it RESUMES the destination session whenever
|
|
5060
|
+
// that file is readable — the conversation it already holds (S4)
|
|
5061
|
+
// or its own emptiness — instead of allocating a copy of nothing. A
|
|
5062
|
+
// session with conversation keeps the same-point gate.
|
|
5063
|
+
if (freshDestPoint !== null
|
|
5064
|
+
&& (sessionEmpty || warpSessions.samePoint(currentPoint, freshDestPoint))) {
|
|
5027
5065
|
recordedSession = recorded;
|
|
5028
5066
|
recordedFreshPoint = freshDestPoint;
|
|
5029
5067
|
}
|
|
@@ -5039,7 +5077,9 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5039
5077
|
if (recordedSessionFile !== null) {
|
|
5040
5078
|
let resumed;
|
|
5041
5079
|
try {
|
|
5042
|
-
resumed = await ctx.switchSession(recordedSessionFile, {
|
|
5080
|
+
resumed = await ctx.switchSession(recordedSessionFile, {
|
|
5081
|
+
withSession: sessionEmpty ? warpSilentWithSession : warpResumeWithSession,
|
|
5082
|
+
});
|
|
5043
5083
|
} catch (err) {
|
|
5044
5084
|
return failAfterCopy(
|
|
5045
5085
|
`worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the recorded warp session was kept`,
|
|
@@ -5066,8 +5106,12 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5066
5106
|
);
|
|
5067
5107
|
emitOsc7(options.oscStream || process.stdout, targetCwd);
|
|
5068
5108
|
return {
|
|
5069
|
-
text: moveText,
|
|
5070
|
-
details: {
|
|
5109
|
+
text: sessionEmpty ? warpEmptyText(name, targetCwd) : moveText,
|
|
5110
|
+
details: {
|
|
5111
|
+
name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
|
|
5112
|
+
alreadyThere: false, cancelled: false,
|
|
5113
|
+
...(sessionEmpty ? { emptySession: true } : {}), ...handoffCopyDetails(),
|
|
5114
|
+
},
|
|
5071
5115
|
};
|
|
5072
5116
|
}
|
|
5073
5117
|
const sm = ctx.sessionManager || {};
|
|
@@ -5082,7 +5126,11 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5082
5126
|
const leaf = typeof sm.getLeafId === "function" ? safe(() => sm.getLeafId()) : null;
|
|
5083
5127
|
const sessionDir = typeof sm.getSessionDir === "function" ? safe(() => sm.getSessionDir()) : undefined;
|
|
5084
5128
|
let carried = [];
|
|
5085
|
-
|
|
5129
|
+
// S1: an empty session is allowed through without a persisted file or
|
|
5130
|
+
// in-memory entries — the refusal below exists because a session with
|
|
5131
|
+
// nothing to carry had nothing to warp FOR, and the empty path now changes
|
|
5132
|
+
// its path instead of carrying anything.
|
|
5133
|
+
if (!sessionEmpty && !warpSourceUsable(file, warpFileOpen)) {
|
|
5086
5134
|
const entries = typeof sm.getEntries === "function" ? safe(() => sm.getEntries()) : undefined;
|
|
5087
5135
|
carried = Array.isArray(entries) ? entries : [];
|
|
5088
5136
|
if (carried.length === 0) {
|
|
@@ -5093,6 +5141,82 @@ async function executeWorktreeWarp(rawName, ctx, opts) {
|
|
|
5093
5141
|
}
|
|
5094
5142
|
}
|
|
5095
5143
|
const SM = options.sessionManagerClass || await sessionManagerClassFor(ctx);
|
|
5144
|
+
// S3 (warp-empty-session): an empty session changes PATH. There is nothing
|
|
5145
|
+
// to carry, so nothing is forked (`forkFrom` refuses a conversation-less
|
|
5146
|
+
// source anyway) and the in-place move the host would need does not exist —
|
|
5147
|
+
// `switchSession(file)` is the only mount, and a session's cwd comes from
|
|
5148
|
+
// its own header. So the equivalent is used: a FRESH session allocated at
|
|
5149
|
+
// the target cwd through the same create/switch machinery the non-persisted
|
|
5150
|
+
// path uses, with only its header written (`wx`, never clobbering) so the
|
|
5151
|
+
// mount and the record both have a real file. The switch is silent: no
|
|
5152
|
+
// card, no transition entry, no rename — bookkeeping this flow did not earn.
|
|
5153
|
+
if (sessionEmpty) {
|
|
5154
|
+
if (!SM || typeof SM.create !== "function") {
|
|
5155
|
+
return failAfterCopy(
|
|
5156
|
+
"worktree warp is unavailable here: the host SessionManager has no create",
|
|
5157
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5158
|
+
);
|
|
5159
|
+
}
|
|
5160
|
+
let emptyFile = null;
|
|
5161
|
+
try {
|
|
5162
|
+
const allocated = SM.create(targetCwd, sessionDir);
|
|
5163
|
+
const allocatedFile = allocated && typeof allocated.getSessionFile === "function" ? allocated.getSessionFile() : undefined;
|
|
5164
|
+
if (typeof allocatedFile !== "string" || allocatedFile.length === 0) {
|
|
5165
|
+
throw new Error("the host allocated no session file for the target");
|
|
5166
|
+
}
|
|
5167
|
+
const header = typeof allocated.getHeader === "function" ? allocated.getHeader() : null;
|
|
5168
|
+
if (!header) throw new Error("the allocated session has no header");
|
|
5169
|
+
// The file is the header alone: there is no entry to carry, and a file
|
|
5170
|
+
// naming the target cwd is what makes the mount and the fresh point
|
|
5171
|
+
// real. `wx` never clobbers an existing file.
|
|
5172
|
+
fs.writeFileSync(allocatedFile, `${JSON.stringify(header)}\n`, { flag: "wx" });
|
|
5173
|
+
emptyFile = allocatedFile;
|
|
5174
|
+
} catch (err) {
|
|
5175
|
+
return failAfterCopy(
|
|
5176
|
+
`worktree warp: could not change this session's path to ${targetCwd}: ${(err && err.message) || err}`,
|
|
5177
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5178
|
+
);
|
|
5179
|
+
}
|
|
5180
|
+
let changed;
|
|
5181
|
+
try {
|
|
5182
|
+
changed = await ctx.switchSession(emptyFile, { withSession: warpSilentWithSession });
|
|
5183
|
+
} catch (err) {
|
|
5184
|
+
return failAfterCopy(
|
|
5185
|
+
`worktree warp: the session switch to ${targetCwd} failed: ${(err && err.message) || err}; the new session file was kept at ${emptyFile}`,
|
|
5186
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: false },
|
|
5187
|
+
);
|
|
5188
|
+
}
|
|
5189
|
+
if (changed && changed.cancelled === true) {
|
|
5190
|
+
try {
|
|
5191
|
+
fs.unlinkSync(emptyFile);
|
|
5192
|
+
} catch {
|
|
5193
|
+
// Best effort: the report below is what matters.
|
|
5194
|
+
}
|
|
5195
|
+
return failAfterCopy(
|
|
5196
|
+
`worktree warp: ${name} cancelled (the session stays in ${fromCwd}; the new session file was removed)`,
|
|
5197
|
+
{ name, path: targetCwd, branch: displayBranch, kind: resolved.kind, alreadyThere: false, cancelled: true },
|
|
5198
|
+
);
|
|
5199
|
+
}
|
|
5200
|
+
// Recording is unchanged: both sides are recorded at their own points, so
|
|
5201
|
+
// a later warp finds this destination session again (S2: warping back
|
|
5202
|
+
// from an empty session is the same empty case).
|
|
5203
|
+
try {
|
|
5204
|
+
recordBothSides(
|
|
5205
|
+
targetCwd, emptyFile, warpFreshPoint(emptyFile),
|
|
5206
|
+
sourceIdentity, currentFile, currentPoint,
|
|
5207
|
+
);
|
|
5208
|
+
} catch {
|
|
5209
|
+
// Best effort: the warp already switched.
|
|
5210
|
+
}
|
|
5211
|
+
emitOsc7(options.oscStream || process.stdout, targetCwd);
|
|
5212
|
+
return {
|
|
5213
|
+
text: warpEmptyText(name, targetCwd),
|
|
5214
|
+
details: {
|
|
5215
|
+
name: displayName, path: targetCwd, branch: displayBranch, kind: resolved.kind,
|
|
5216
|
+
alreadyThere: false, cancelled: false, emptySession: true, ...handoffCopyDetails(),
|
|
5217
|
+
},
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
5096
5220
|
if (!SM || typeof SM.forkFrom !== "function") {
|
|
5097
5221
|
return failAfterCopy(
|
|
5098
5222
|
"worktree warp is unavailable here: the host SessionManager has no forkFrom",
|
|
@@ -7528,6 +7652,65 @@ function registerIssuesCommand(pi) {
|
|
|
7528
7652
|
});
|
|
7529
7653
|
}
|
|
7530
7654
|
|
|
7655
|
+
// ---------------------------------------------------------------------------
|
|
7656
|
+
// `/codegraph-hook`, `/codegraph-init`, `/codegraph-sync` (T5 S9): the sync
|
|
7657
|
+
// hook's switch and the two on-demand CLI runs. The bodies live in
|
|
7658
|
+
// lib/codegraph-commands.js so they are testable without a host; this file only
|
|
7659
|
+
// registers them and hands their one-line result to the session UI.
|
|
7660
|
+
//
|
|
7661
|
+
// GATED REGISTRATION: the three commands do not exist at all in a session whose
|
|
7662
|
+
// gate says the `codegraph` CLI is absent, so they never appear in that
|
|
7663
|
+
// session's command list. The gate caches its answer per process (S3), so
|
|
7664
|
+
// asking at registration time costs one PATH probe for the whole session.
|
|
7665
|
+
function codegraphCommandsAvailable() {
|
|
7666
|
+
try {
|
|
7667
|
+
return libRequire("codegraph-session.js").hasCodegraph(process.env) === true;
|
|
7668
|
+
} catch {
|
|
7669
|
+
return false;
|
|
7670
|
+
}
|
|
7671
|
+
}
|
|
7672
|
+
|
|
7673
|
+
function registerCodegraphCommands(pi) {
|
|
7674
|
+
if (typeof pi?.registerCommand !== "function") return;
|
|
7675
|
+
if (!codegraphCommandsAvailable()) return;
|
|
7676
|
+
const render = (ctx, result) => {
|
|
7677
|
+
const notify = ctx && ctx.ui ? ctx.ui.notify : undefined;
|
|
7678
|
+
if (typeof notify === "function") notify(result.text, result.level);
|
|
7679
|
+
return result;
|
|
7680
|
+
};
|
|
7681
|
+
// A command never throws into the host: the lib returns a failure line
|
|
7682
|
+
// instead of rejecting, and anything unforeseen is swallowed here.
|
|
7683
|
+
const run = async (ctx, work) => {
|
|
7684
|
+
try {
|
|
7685
|
+
render(ctx, await work());
|
|
7686
|
+
} catch {
|
|
7687
|
+
// Nothing to report that the lib did not already describe.
|
|
7688
|
+
}
|
|
7689
|
+
};
|
|
7690
|
+
pi.registerCommand("codegraph-hook", {
|
|
7691
|
+
description: "Turn the automatic edit/write CodeGraph sync on or off: on, off, status (no argument toggles)",
|
|
7692
|
+
handler: async (args, ctx) => {
|
|
7693
|
+
await run(ctx, () => libRequire("codegraph-commands.js").codegraphHook(args));
|
|
7694
|
+
},
|
|
7695
|
+
});
|
|
7696
|
+
pi.registerCommand("codegraph-init", {
|
|
7697
|
+
description: "Initialize a CodeGraph index in this session's checkout: codegraph init .",
|
|
7698
|
+
handler: async (args, ctx) => {
|
|
7699
|
+
await run(ctx, () => libRequire("codegraph-commands.js").codegraphInit(args, ctx, {
|
|
7700
|
+
run: libRunner(libRequire("worktree-git.js")),
|
|
7701
|
+
}));
|
|
7702
|
+
},
|
|
7703
|
+
});
|
|
7704
|
+
pi.registerCommand("codegraph-sync", {
|
|
7705
|
+
description: "Sync this session's checkout CodeGraph index: codegraph sync .",
|
|
7706
|
+
handler: async (args, ctx) => {
|
|
7707
|
+
await run(ctx, () => libRequire("codegraph-commands.js").codegraphSync(args, ctx, {
|
|
7708
|
+
run: libRunner(libRequire("worktree-git.js")),
|
|
7709
|
+
}));
|
|
7710
|
+
},
|
|
7711
|
+
});
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7531
7714
|
export default function (pi) {
|
|
7532
7715
|
// A TOOL context has no session control, so `worktree_warp` queues the
|
|
7533
7716
|
// `/warp` COMMAND through this handle (`sendUserMessage` with
|
|
@@ -7537,6 +7720,7 @@ export default function (pi) {
|
|
|
7537
7720
|
registerWarpCommand(pi);
|
|
7538
7721
|
registerOddCommand(pi);
|
|
7539
7722
|
registerIssuesCommand(pi);
|
|
7723
|
+
registerCodegraphCommands(pi);
|
|
7540
7724
|
pi.registerTool(worktreeCreateTool);
|
|
7541
7725
|
pi.registerTool(worktreeListTool);
|
|
7542
7726
|
pi.registerTool(worktreeCloseTool);
|
|
@@ -7560,6 +7744,30 @@ export default function (pi) {
|
|
|
7560
7744
|
});
|
|
7561
7745
|
pi.on("tool_call", (event, ctx) => handleWorktreeOddEvent(event, ctx, pi));
|
|
7562
7746
|
pi.on("tool_result", (event, ctx) => handleWorktreeOddEvent(event, ctx, pi, true));
|
|
7747
|
+
// S1/S3: a successful edit or write asks for ONE background `codegraph sync`
|
|
7748
|
+
// of that file's checkout. The call is fire-and-forget — never awaited, its
|
|
7749
|
+
// decision never reaches the tool result, and a failure here is swallowed
|
|
7750
|
+
// instead of surfacing as a broken edit.
|
|
7751
|
+
pi.on("tool_result", (event) => {
|
|
7752
|
+
try {
|
|
7753
|
+
if (!event || (event.toolName !== "edit" && event.toolName !== "write")) return;
|
|
7754
|
+
if (event.isError) return;
|
|
7755
|
+
const filePath = event.input && event.input.path;
|
|
7756
|
+
if (typeof filePath !== "string" || filePath.length === 0) return;
|
|
7757
|
+
libRequire("codegraph-session.js").scheduleSync(filePath);
|
|
7758
|
+
} catch {
|
|
7759
|
+
// Index freshness is best effort and never touches the tool result.
|
|
7760
|
+
}
|
|
7761
|
+
});
|
|
7762
|
+
// S7: the ODD worktree rules ride the prompt options, appended after the
|
|
7763
|
+
// harness's own text. Sync mutation, so the host reads the final string.
|
|
7764
|
+
pi.on("before_agent_start", (event) => {
|
|
7765
|
+
try {
|
|
7766
|
+
libRequire("odd-worktrees-prompt.js").appendOddWorktreesSection(event);
|
|
7767
|
+
} catch {
|
|
7768
|
+
// The prompt block is best effort.
|
|
7769
|
+
}
|
|
7770
|
+
});
|
|
7563
7771
|
// Register every live checkout the registry does not own yet. Dispatched here
|
|
7564
7772
|
// and awaited by the barrier below: the host runs session_start handlers one
|
|
7565
7773
|
// at a time, so awaiting the scan inside this handler would put the session's
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The three CodeGraph session commands' bodies (S9): `/codegraph-hook`,
|
|
3
|
+
// `/codegraph-init` and `/codegraph-sync`.
|
|
4
|
+
//
|
|
5
|
+
// They are plain functions returning a render-ready result — `{ok, level,
|
|
6
|
+
// text}` — so the extension only has to hand the line to `ctx.ui.notify`. The
|
|
7
|
+
// registration itself is the extension's job and is gated on the session gate,
|
|
8
|
+
// so a session without the CLI never lists these commands.
|
|
9
|
+
//
|
|
10
|
+
// `/codegraph-hook` flips the in-memory, process-wide switch owned by
|
|
11
|
+
// `codegraph-session.js`: nothing is written to user configuration, and the
|
|
12
|
+
// environment (`CODEGRAPH_HOOK=0`) is only the starting state.
|
|
13
|
+
//
|
|
14
|
+
// `init` and `sync` run the real CLI once, at the session's current directory,
|
|
15
|
+
// through the injected `run` seam. The reported line is the child's real
|
|
16
|
+
// outcome: a non-zero (or unstartable) child is a failure, and no path throws
|
|
17
|
+
// into the command handler.
|
|
18
|
+
|
|
19
|
+
const { isHookEnabled, setHookEnabled } = require('./codegraph-session.js');
|
|
20
|
+
|
|
21
|
+
const CODEGRAPH_BIN = 'codegraph';
|
|
22
|
+
const COMMANDS = { init: 'codegraph init', sync: 'codegraph sync' };
|
|
23
|
+
|
|
24
|
+
function pick(deps, name, fallback) {
|
|
25
|
+
return deps && typeof deps[name] === 'function' ? deps[name] : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The convention every other lib helper uses for its runner;
|
|
29
|
+
// `runArgvAsync` resolves (never rejects) with `{status, stdout, stderr, error}`.
|
|
30
|
+
function defaultRun(bin, args, cwd) {
|
|
31
|
+
return require('./worktree-git.js').runArgvAsync(bin, args, { cwd });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sessionCwd(ctx) {
|
|
35
|
+
const cwd = ctx && ctx.cwd;
|
|
36
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One line of child output, so a multi-line stack never becomes the message.
|
|
40
|
+
function firstLine(text) {
|
|
41
|
+
const raw = String(text === undefined || text === null ? '' : text);
|
|
42
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (trimmed.length > 0) return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hookState() {
|
|
50
|
+
return isHookEnabled() ? 'enabled' : 'disabled';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `/codegraph-hook` — no argument toggles; `on` and `off` set the state
|
|
54
|
+
// explicitly; `status` only reports it.
|
|
55
|
+
function codegraphHook(rawArgs) {
|
|
56
|
+
const raw = String(rawArgs === undefined || rawArgs === null ? '' : rawArgs).trim().toLowerCase();
|
|
57
|
+
if (raw === '' || raw === 'on' || raw === 'off') setHookEnabled(raw === '' ? !isHookEnabled() : raw === 'on');
|
|
58
|
+
else if (raw !== 'status') {
|
|
59
|
+
return { ok: false, level: 'error', text: `codegraph hook: unknown option "${raw}". Use on, off or status.` };
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, level: 'info', text: `codegraph hook: ${hookState()}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The CLI is chatty and decorative (a box around every line, ANSI colours, and
|
|
65
|
+
// glyphs that change between releases), so the report is built from the plain
|
|
66
|
+
// text it puts on stdout and stderr rather than from the exit status alone. The
|
|
67
|
+
// captured shapes this reads:
|
|
68
|
+
//
|
|
69
|
+
// init, fresh "Initialized in <path>" / "Indexed N files" / "N nodes, M edges in Tms"
|
|
70
|
+
// init, indexed "Already initialized in <path>" / "Use \"codegraph index\" to re-index ..."
|
|
71
|
+
// sync, clean "Already up to date"
|
|
72
|
+
// sync, changed "Synced N changed files" / "Modified: N — M nodes in Tms"
|
|
73
|
+
// sync, no index "CodeGraph not initialized in <path>" (stderr, exit 1)
|
|
74
|
+
const ANSI = /\u001b\[[0-9;]*[a-zA-Z]/g;
|
|
75
|
+
|
|
76
|
+
function plain(text) {
|
|
77
|
+
return String(text === undefined || text === null ? '' : text).replace(ANSI, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function count(value, noun) {
|
|
81
|
+
const n = Number(value);
|
|
82
|
+
if (!Number.isFinite(n)) return '';
|
|
83
|
+
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function summarize(label, out) {
|
|
87
|
+
const text = `${plain(out.stdout)}\n${plain(out.stderr)}`;
|
|
88
|
+
const path = (match) => (match && match[1] ? match[1].trim() : '');
|
|
89
|
+
|
|
90
|
+
const notIndexed = text.match(/not initialized in\s+(\S+)/i);
|
|
91
|
+
if (notIndexed) {
|
|
92
|
+
return { ok: false, level: 'error', text: `${label}: no index in this checkout — run /codegraph-init first` };
|
|
93
|
+
}
|
|
94
|
+
const already = text.match(/already initialized in\s+(\S+)/i);
|
|
95
|
+
if (already) {
|
|
96
|
+
return { ok: true, level: 'info', text: `${label}: already initialized at ${path(already)} — nothing to do, use /codegraph-sync to update` };
|
|
97
|
+
}
|
|
98
|
+
const upToDate = /already up to date/i.test(text);
|
|
99
|
+
if (upToDate) {
|
|
100
|
+
return { ok: true, level: 'info', text: `${label}: already up to date — nothing to sync` };
|
|
101
|
+
}
|
|
102
|
+
const synced = text.match(/synced\s+(\d+)\s+changed\s+files?/i);
|
|
103
|
+
if (synced) {
|
|
104
|
+
const nodes = text.match(/(\d+)\s+nodes?/i);
|
|
105
|
+
const counts = [count(synced[1], 'changed file'), nodes ? count(nodes[1], 'node') : ''].filter(Boolean).join(' — ');
|
|
106
|
+
return { ok: true, level: 'info', text: `${label}: synced ${counts}` };
|
|
107
|
+
}
|
|
108
|
+
const initialized = text.match(/initialized in\s+(\S+)/i);
|
|
109
|
+
if (initialized) {
|
|
110
|
+
const files = text.match(/indexed\s+(\d+)\s+files?/i);
|
|
111
|
+
const nodes = text.match(/(\d+)\s+nodes?/i);
|
|
112
|
+
const edges = text.match(/(\d+)\s+edges?/i);
|
|
113
|
+
const sorted = [
|
|
114
|
+
files ? count(files[1], 'file') : '',
|
|
115
|
+
nodes ? count(nodes[1], 'node') : '',
|
|
116
|
+
edges ? count(edges[1], 'edge') : '',
|
|
117
|
+
].filter(Boolean).join(', ');
|
|
118
|
+
return { ok: true, level: 'info', text: `${label}: initialized ${path(initialized)}${sorted ? ` — ${sorted}` : ''}` };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runStep(verb, rawArgs, ctx, deps) {
|
|
124
|
+
const label = COMMANDS[verb];
|
|
125
|
+
const raw = String(rawArgs === undefined || rawArgs === null ? '' : rawArgs).trim();
|
|
126
|
+
if (raw !== '') return { ok: false, level: 'error', text: `${label}: takes no argument.` };
|
|
127
|
+
|
|
128
|
+
const cwd = sessionCwd(ctx);
|
|
129
|
+
let outcome;
|
|
130
|
+
try {
|
|
131
|
+
outcome = await pick(deps, 'run', defaultRun)(CODEGRAPH_BIN, [verb, '.'], cwd);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { ok: false, level: 'error', text: `${label}: failed — ${firstLine((err && err.message) || String(err))}` };
|
|
134
|
+
}
|
|
135
|
+
const out = outcome && typeof outcome === 'object' ? outcome : {};
|
|
136
|
+
const summary = summarize(label, out);
|
|
137
|
+
// The CLI answers `sync` in a checkout with no index on stderr with exit 1,
|
|
138
|
+
// so the recognised message decides before the exit status does.
|
|
139
|
+
if (summary && (summary.ok || /no index/.test(summary.text))) return summary;
|
|
140
|
+
if (out.error) {
|
|
141
|
+
return { ok: false, level: 'error', text: `${label}: failed — ${firstLine(out.error) || 'unknown error'}` };
|
|
142
|
+
}
|
|
143
|
+
if (out.status !== 0) {
|
|
144
|
+
const exit = Number.isInteger(out.status) ? out.status : 'unknown';
|
|
145
|
+
const detail = firstLine(plain(out.stderr)) || firstLine(plain(out.stdout)) || 'no output';
|
|
146
|
+
return { ok: false, level: 'error', text: `${label}: exit ${exit} — ${detail}` };
|
|
147
|
+
}
|
|
148
|
+
return summary || { ok: true, level: 'info', text: `${label}: done` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `/codegraph-init` and `/codegraph-sync` — one awaited CLI run at the session's
|
|
152
|
+
// current directory, reported by its real exit status.
|
|
153
|
+
function codegraphInit(rawArgs, ctx, deps) {
|
|
154
|
+
return runStep('init', rawArgs, ctx, deps);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function codegraphSync(rawArgs, ctx, deps) {
|
|
158
|
+
return runStep('sync', rawArgs, ctx, deps);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
codegraphHook,
|
|
163
|
+
codegraphInit,
|
|
164
|
+
codegraphSync,
|
|
165
|
+
};
|