claude-dev-env 7.1.0 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/everything-search/SKILL.md +9 -4
- package/.agents/skills/everything-search/scripts/everything_search.py +31 -15
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +6 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +68 -1
- package/.agents/skills/session-continuity/README.md +46 -22
- package/.agents/skills/session-continuity/SKILL.md +19 -8
- package/.agents/skills/session-continuity/continuity.mjs +91 -19
- package/.agents/skills/session-continuity/continuity.test.mjs +79 -9
- package/bin/install-session-continuity.mjs +24 -14
- package/bin/install-session-continuity.test.mjs +18 -5
- package/bin/install.test.mjs +17 -0
- package/hooks/blocking/content_search_to_zoekt_redirector.py +1 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector.py +46 -0
- package/hooks/hooks.json +2 -2
- package/package.json +1 -1
- package/rules/filesystem-search.md +1 -1
|
@@ -15,7 +15,11 @@ Search files instantly on Windows using the Everything command-line interface (e
|
|
|
15
15
|
|
|
16
16
|
Every search carries a scope: a project path or registry token, an `ext:` filter, a `dm:` date filter, a `size:` filter, or a name pattern. A bare whole-drive scan or a network-share sweep is out of bounds. Narrow the search to what you need.
|
|
17
17
|
|
|
18
|
-
When `es.exe`
|
|
18
|
+
When `es.exe` returns Error 8, the Everything IPC client window is missing. Retry the same search twice with a short pause. Do not report that Everything is not running, not loaded, or that the index is unavailable from that first miss. After those retries fail, report that the Everything IPC client did not answer and that the service state was not probed, then fall back to the `Glob` tool (name and path patterns) or `Grep` (file contents) with the same scope.
|
|
19
|
+
|
|
20
|
+
When `es.exe` is missing, or a later search returns no hits, fall back to `Glob` or `Grep`. A failed search is not proof the index is down. When those fallbacks also fail, ask the user through `AskUserQuestion` with a short analysis and next-step options.
|
|
21
|
+
|
|
22
|
+
Direct `es.exe` calls use the same Error 8 retry. The wrapper in `scripts/everything_search.py` already retries Error 8 twice.
|
|
19
23
|
|
|
20
24
|
## Registry tokens
|
|
21
25
|
|
|
@@ -139,7 +143,8 @@ Run separate searches for each extension type:
|
|
|
139
143
|
|
|
140
144
|
## Best Practices
|
|
141
145
|
|
|
142
|
-
- Everything
|
|
146
|
+
- `es.exe` talks to the Everything tray client IPC window. The Everything service can be running while Error 8 still fires.
|
|
147
|
+
- On Error 8, retry the same search twice before treating the search as failed.
|
|
143
148
|
- Run one extension per search for cleaner results
|
|
144
149
|
- Use `dm:` to filter recent files by the index timestamp
|
|
145
150
|
- Combine with path to narrow scope
|
|
@@ -165,5 +170,5 @@ Everything indexes REAL paths only, not junctions or mapped drives.
|
|
|
165
170
|
|
|
166
171
|
## Files
|
|
167
172
|
|
|
168
|
-
- `scripts/everything_search.py` runs one Everything search without a shell.
|
|
169
|
-
- `scripts/test_everything_search.py` verifies registry expansion and
|
|
173
|
+
- `scripts/everything_search.py` runs one Everything search without a shell, retries Error 8 twice, and reports that the IPC client did not answer and that the service state was not probed when those retries fail.
|
|
174
|
+
- `scripts/test_everything_search.py` verifies registry expansion, command failures, Error 8 retry, and the IPC-client miss message.
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import shutil
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from collections.abc import Mapping, Sequence
|
|
8
9
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
9
10
|
from typing import TextIO
|
|
@@ -14,6 +15,10 @@ from everything_search_command_constants.config.constants import (
|
|
|
14
15
|
EXECUTION_ERROR_EXIT_CODE,
|
|
15
16
|
INFORMATIONAL_ARGUMENT,
|
|
16
17
|
INVALID_INPUT_EXIT_CODE,
|
|
18
|
+
IPC_CLIENT_DID_NOT_ANSWER_MESSAGE,
|
|
19
|
+
IPC_WINDOW_NOT_FOUND_EXIT_CODE,
|
|
20
|
+
IPC_WINDOW_RETRY_COUNT,
|
|
21
|
+
IPC_WINDOW_RETRY_DELAY_SECONDS,
|
|
17
22
|
PROJECT_PATHS_FILE_NAME,
|
|
18
23
|
REGISTRY_META_KEY,
|
|
19
24
|
SEARCH_SCOPE_REQUIRED_MESSAGE,
|
|
@@ -115,21 +120,32 @@ def _run_search(
|
|
|
115
120
|
search_stdout: TextIO,
|
|
116
121
|
search_stderr: TextIO,
|
|
117
122
|
) -> int:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
ipc_client_did_not_answer_message = IPC_CLIENT_DID_NOT_ANSWER_MESSAGE
|
|
124
|
+
ipc_window_not_found_exit_code = IPC_WINDOW_NOT_FOUND_EXIT_CODE
|
|
125
|
+
ipc_window_retry_count = IPC_WINDOW_RETRY_COUNT
|
|
126
|
+
ipc_window_retry_delay_seconds = IPC_WINDOW_RETRY_DELAY_SECONDS
|
|
127
|
+
last_search_run = None
|
|
128
|
+
for each_attempt_index in range(ipc_window_retry_count + 1):
|
|
129
|
+
try:
|
|
130
|
+
last_search_run = subprocess.run(
|
|
131
|
+
[executable_path, *all_search_arguments],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
check=False,
|
|
134
|
+
encoding=UTF8_ENCODING,
|
|
135
|
+
shell=False,
|
|
136
|
+
text=True,
|
|
137
|
+
)
|
|
138
|
+
except OSError as error:
|
|
139
|
+
search_stderr.write(f"{error}\n")
|
|
140
|
+
return EXECUTION_ERROR_EXIT_CODE
|
|
141
|
+
if last_search_run.returncode != ipc_window_not_found_exit_code:
|
|
142
|
+
search_stdout.write(last_search_run.stdout)
|
|
143
|
+
search_stderr.write(last_search_run.stderr)
|
|
144
|
+
return last_search_run.returncode
|
|
145
|
+
if each_attempt_index < ipc_window_retry_count:
|
|
146
|
+
time.sleep(ipc_window_retry_delay_seconds)
|
|
147
|
+
search_stderr.write(ipc_client_did_not_answer_message)
|
|
148
|
+
return ipc_window_not_found_exit_code
|
|
133
149
|
|
|
134
150
|
|
|
135
151
|
def _run_registered_search(
|
|
@@ -5,6 +5,12 @@ EXECUTABLE_NAME = "es.exe"
|
|
|
5
5
|
EXECUTION_ERROR_EXIT_CODE = 1
|
|
6
6
|
INFORMATIONAL_ARGUMENT = "-version"
|
|
7
7
|
INVALID_INPUT_EXIT_CODE = 2
|
|
8
|
+
IPC_CLIENT_DID_NOT_ANSWER_MESSAGE = (
|
|
9
|
+
"Everything IPC client did not answer. Everything service state was not probed.\n"
|
|
10
|
+
)
|
|
11
|
+
IPC_WINDOW_NOT_FOUND_EXIT_CODE = 8
|
|
12
|
+
IPC_WINDOW_RETRY_COUNT = 2
|
|
13
|
+
IPC_WINDOW_RETRY_DELAY_SECONDS = 0.5
|
|
8
14
|
PROJECT_PATHS_FILE_NAME = "project-paths.json"
|
|
9
15
|
REGISTRY_META_KEY = "_meta"
|
|
10
16
|
SEARCH_SCOPE_REQUIRED_MESSAGE = "The first search argument must define a path, project, name, extension, date, or size scope.\n"
|
|
@@ -113,12 +113,79 @@ def test_main_passes_through_child_exit_status() -> None:
|
|
|
113
113
|
with (
|
|
114
114
|
patch.object(search, "load_registry", return_value={}),
|
|
115
115
|
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
116
|
-
patch.object(
|
|
116
|
+
patch.object(
|
|
117
|
+
search.subprocess, "run", return_value=completed_process
|
|
118
|
+
) as run_process,
|
|
117
119
|
patch.object(search.sys, "stderr", captured_stderr),
|
|
118
120
|
):
|
|
119
121
|
exit_code = search.main(["config.py"])
|
|
120
122
|
assert exit_code == 7
|
|
121
123
|
assert captured_stderr.getvalue() == "failed\n"
|
|
124
|
+
run_process.assert_called_once()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ipc_window_error_process() -> search.subprocess.CompletedProcess:
|
|
128
|
+
return search.subprocess.CompletedProcess(
|
|
129
|
+
args=["es.exe", "config.py"],
|
|
130
|
+
returncode=8,
|
|
131
|
+
stdout="",
|
|
132
|
+
stderr=(
|
|
133
|
+
"Error 8: Everything IPC window not found. "
|
|
134
|
+
"Please make sure Everything is running.\n"
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_main_retries_ipc_window_error_then_returns_search_hits() -> None:
|
|
140
|
+
successful_process = search.subprocess.CompletedProcess(
|
|
141
|
+
args=["es.exe", "config.py"],
|
|
142
|
+
returncode=0,
|
|
143
|
+
stdout="found path\n",
|
|
144
|
+
stderr="",
|
|
145
|
+
)
|
|
146
|
+
captured_stdout = io.StringIO()
|
|
147
|
+
captured_stderr = io.StringIO()
|
|
148
|
+
with (
|
|
149
|
+
patch.object(search, "load_registry", return_value={}),
|
|
150
|
+
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
151
|
+
patch.object(
|
|
152
|
+
search.subprocess,
|
|
153
|
+
"run",
|
|
154
|
+
side_effect=[_ipc_window_error_process(), successful_process],
|
|
155
|
+
) as run_process,
|
|
156
|
+
patch.object(search.sys, "stdout", captured_stdout),
|
|
157
|
+
patch.object(search.sys, "stderr", captured_stderr),
|
|
158
|
+
patch("time.sleep"),
|
|
159
|
+
):
|
|
160
|
+
exit_code = search.main(["config.py"])
|
|
161
|
+
assert exit_code == 0
|
|
162
|
+
assert captured_stdout.getvalue() == "found path\n"
|
|
163
|
+
assert "not loaded" not in captured_stderr.getvalue().casefold()
|
|
164
|
+
assert "not running" not in captured_stderr.getvalue().casefold()
|
|
165
|
+
assert run_process.call_count == 2
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_main_reports_ipc_client_miss_after_ipc_window_retries_exhaust() -> None:
|
|
169
|
+
captured_stderr = io.StringIO()
|
|
170
|
+
with (
|
|
171
|
+
patch.object(search, "load_registry", return_value={}),
|
|
172
|
+
patch.object(search.shutil, "which", return_value="es.exe"),
|
|
173
|
+
patch.object(
|
|
174
|
+
search.subprocess,
|
|
175
|
+
"run",
|
|
176
|
+
return_value=_ipc_window_error_process(),
|
|
177
|
+
) as run_process,
|
|
178
|
+
patch.object(search.sys, "stderr", captured_stderr),
|
|
179
|
+
patch("time.sleep"),
|
|
180
|
+
):
|
|
181
|
+
exit_code = search.main(["config.py"])
|
|
182
|
+
assert exit_code == 8
|
|
183
|
+
stderr_text = captured_stderr.getvalue()
|
|
184
|
+
assert "Everything IPC client did not answer" in stderr_text
|
|
185
|
+
assert "service state was not probed" in stderr_text.casefold()
|
|
186
|
+
assert "Please make sure Everything is running" not in stderr_text
|
|
187
|
+
assert "not loaded" not in stderr_text.casefold()
|
|
188
|
+
assert run_process.call_count == 3
|
|
122
189
|
|
|
123
190
|
|
|
124
191
|
def test_main_reports_missing_executable() -> None:
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# Session continuity companion
|
|
2
2
|
|
|
3
|
-
Status: draft implementation. Claude and
|
|
4
|
-
contract tests. Installed-host activation, reload, and agent
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Status: draft implementation. Claude, Codex, and Cursor adapters have
|
|
4
|
+
repository-level contract tests. Installed-host activation, reload, and agent
|
|
5
|
+
compliance remain unverified.
|
|
6
|
+
|
|
7
|
+
Every supported host activates Poteto Mode automatically when a session starts
|
|
8
|
+
and when the host resumes the conversation after compaction. An explicit
|
|
9
|
+
invocation still works and still records the user's own scope.
|
|
7
10
|
|
|
8
11
|
## The persistence failure
|
|
9
12
|
|
|
@@ -12,6 +15,10 @@ lose its duration, later corrections, or the instruction to reload it. This
|
|
|
12
15
|
companion saves user-scoped requirements under a stable host-session key and
|
|
13
16
|
returns the current skill contents during supported recovery callbacks.
|
|
14
17
|
|
|
18
|
+
Automatic activation removes the first half of that failure. A session that
|
|
19
|
+
never carried an explicit invocation still starts with Poteto Mode loaded, so
|
|
20
|
+
the mode does not depend on the user retyping it after each restart.
|
|
21
|
+
|
|
15
22
|
## Ownership and setup
|
|
16
23
|
|
|
17
24
|
Pstack owns `Poteto Mode`, directory `poteto-mode`. Its source description names
|
|
@@ -33,7 +40,8 @@ The existing selective `--only core` list does not include the companion in this
|
|
|
33
40
|
draft. Use the full install above. Plugin-only installations need the same
|
|
34
41
|
canonical installed skill before this setup can run.
|
|
35
42
|
|
|
36
|
-
The setup defaults to `claude codex`. Pass
|
|
43
|
+
The setup defaults to `claude codex cursor`. Pass one or more host names to
|
|
44
|
+
configure that subset alone.
|
|
37
45
|
It uses `bin/resolve-install-root.mjs`, including `CLAUDE_CONFIG_DIR` and
|
|
38
46
|
`CODEX_HOME`. It merges only its own hook groups into Claude `settings.json` and
|
|
39
47
|
Codex `hooks.json`, preserves other settings, writes a one-time backup before
|
|
@@ -51,10 +59,13 @@ that has not been tested. Use builds exposing the documented events below.
|
|
|
51
59
|
|
|
52
60
|
| Host | Adapter input | Forms recognized by the companion |
|
|
53
61
|
| --- | --- | --- |
|
|
62
|
+
| Claude Code | `SessionStart` with source `startup`, `resume`, `compact`, `clear`, or `fork` | Automatic session-scope activation, no user text needed |
|
|
54
63
|
| Claude Code | `UserPromptExpansion`, `expansion_type=slash_command` | Existing `poteto-mode` and `pstack:poteto-mode` command names |
|
|
55
64
|
| Claude Code | `UserPromptSubmit` | A leading `/poteto-mode` or `/pstack:poteto-mode`; exact natural forms below |
|
|
65
|
+
| Codex | `SessionStart` with source `startup`, `resume`, `compact`, or `clear` | Automatic session-scope activation, no user text needed |
|
|
56
66
|
| Codex | `UserPromptSubmit` | A leading `$poteto-mode`, `$pstack:poteto-mode`, or `[$pstack:poteto-mode](path)` skill reference; exact natural forms below |
|
|
57
|
-
| Cursor |
|
|
67
|
+
| Cursor | `sessionStart` | Automatic session-scope activation, returned as `additional_context` |
|
|
68
|
+
| Cursor | `beforeSubmitPrompt` | The same leading command and natural forms; the prompt is never blocked |
|
|
58
69
|
|
|
59
70
|
Natural forms include bare `poteto`, `Use Poteto Mode`, `Activate Poteto Mode`,
|
|
60
71
|
`Invoke Poteto Mode`, and `Poteto Mode applies for this entire session`. Exact
|
|
@@ -65,6 +76,12 @@ Free-form style requests and host UI forms that omit the skill token from the
|
|
|
65
76
|
prompt remain unverified. The adapter's recognition is not proof that every host
|
|
66
77
|
resolves every form as a native skill invocation.
|
|
67
78
|
|
|
79
|
+
Automatic activation records one session-scope skill requirement carrying the
|
|
80
|
+
`automatic` flag. A later explicit invocation writes the entry again with the
|
|
81
|
+
user's own evidence quote and scope. An explicit deactivation stops automatic
|
|
82
|
+
recovery for the same host session; a Claude or Codex `clear` starts a fresh
|
|
83
|
+
automatic set under the reused session id.
|
|
84
|
+
|
|
68
85
|
The companion does not define a `/potato-mode` alias or rename `poteto-mode`.
|
|
69
86
|
Quotations, fenced examples, indented code, discussion, imported transcripts, and
|
|
70
87
|
tool-result callbacks do not independently activate the companion. Subsequent
|
|
@@ -111,12 +128,14 @@ messages and source comparison snapshots and stay outside the repository.
|
|
|
111
128
|
|
|
112
129
|
| Situation | Discovery and restoration |
|
|
113
130
|
| --- | --- |
|
|
114
|
-
|
|
|
131
|
+
| Session start with no record | Hook creates an active record holding a session-scope Poteto Mode requirement and returns its path, read-back, and current source |
|
|
132
|
+
| First recognized invocation | Hook creates or updates the record, returns its exact path and read-back, and includes the complete companion instructions |
|
|
115
133
|
| Later user prompt | Same session key; pending evidence is saved and the companion directs the agent to reconcile it before dependent work |
|
|
116
134
|
| Claude/Codex startup or resume | `SessionStart` with the same id finds the active record and reads current active skill sources |
|
|
117
135
|
| Claude/Codex compaction | `SessionStart` with source `compact` reloads the same record and sources |
|
|
118
|
-
|
|
|
119
|
-
|
|
|
136
|
+
| Cursor compaction | `preCompact` marks the record for reload; the next `postToolUse` returns the record and sources as `additional_context` |
|
|
137
|
+
| New unrelated conversation | A new id starts a fresh automatic set and inherits no task, rule, or pending message |
|
|
138
|
+
| Clear | The same-id record holds a fresh automatic set |
|
|
120
139
|
| Explicit handoff | The source command binds a snapshot to a known destination host/id; its next supported start callback discovers that record |
|
|
121
140
|
| Explicit deactivation | An inactive tombstone suppresses recovery until another explicit invocation creates a fresh active set |
|
|
122
141
|
|
|
@@ -136,15 +155,20 @@ host-truncated source in full before dependent work. Missing sources produce an
|
|
|
136
155
|
explicit unavailable message. Changed sources include current contents and a
|
|
137
156
|
bounded before/after comparison. Stored snapshots are for comparison only.
|
|
138
157
|
|
|
139
|
-
Cursor's
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
Cursor's documented `sessionStart` output carries `additional_context`, so the
|
|
159
|
+
Cursor adapter loads the record and the current sources when a composer
|
|
160
|
+
conversation is created. Cursor has no post-compaction event that returns agent
|
|
161
|
+
context: `preCompact` runs before compaction and returns only a user message.
|
|
162
|
+
The adapter therefore marks the record during `preCompact` and returns the
|
|
163
|
+
record and sources through the next `postToolUse`, whose documented output
|
|
164
|
+
includes `additional_context`. On Cursor the reload lands with the agent's first
|
|
165
|
+
tool result after compaction rather than before it, which is the earliest
|
|
166
|
+
documented delivery point.
|
|
167
|
+
|
|
168
|
+
Cursor's `beforeSubmitPrompt` output carries `continue` and a user message, not
|
|
169
|
+
agent context. The adapter uses it to save pending user evidence and to mark an
|
|
170
|
+
explicit invocation for the next reload. It always returns `continue: true`, so
|
|
171
|
+
a companion failure never blocks the user's prompt.
|
|
148
172
|
|
|
149
173
|
Automatic prompt capture and source emission are code paths. Selecting actual
|
|
150
174
|
user rules, checkpointing, loading shortened sources, and following instructions
|
|
@@ -153,7 +177,7 @@ compliance merely because a callback ran.
|
|
|
153
177
|
|
|
154
178
|
## Validation and remaining installed-host proof
|
|
155
179
|
|
|
156
|
-
Run the repository contract tests:
|
|
180
|
+
Run the repository contract tests, which cover all three host adapters:
|
|
157
181
|
|
|
158
182
|
```sh
|
|
159
183
|
node --test packages/claude-dev-env/.agents/skills/session-continuity/continuity.test.mjs \
|
|
@@ -177,10 +201,10 @@ real compaction and a same-conversation restart, supply no record path, and
|
|
|
177
201
|
capture discovery plus source reload before dependent work. Repeat with a later
|
|
178
202
|
correction, a new skill, changed/missing sources, simultaneous sessions,
|
|
179
203
|
quoted-only mentions, explicit handoff, deactivation, and reactivation. Keep
|
|
180
|
-
Poteto's source hash unchanged throughout. Cursor
|
|
181
|
-
|
|
204
|
+
Poteto's source hash unchanged throughout. On Cursor, also capture the tool call
|
|
205
|
+
whose `postToolUse` result carries the reload after a compaction.
|
|
182
206
|
|
|
183
|
-
## Primary references checked on 2026-09-
|
|
207
|
+
## Primary references checked on 2026-09-07
|
|
184
208
|
|
|
185
209
|
- [Pstack Poteto Mode source](https://github.com/cursor/plugins/blob/main/pstack/skills/poteto-mode/SKILL.md)
|
|
186
210
|
- [Claude Code hooks](https://code.claude.com/docs/en/hooks)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: session-continuity
|
|
3
|
-
description: Preserve user-scoped task requirements and named skills in a host-session record. Loaded by the separate Poteto companion hook or explicitly invoked as session-continuity. triggers with poteto-mode, poteto, /poteto-mode, /poteto.
|
|
3
|
+
description: Preserve user-scoped task requirements and named skills in a host-session record, and activate Poteto Mode automatically at session start and after compaction. Loaded by the separate Poteto companion hook or explicitly invoked as session-continuity. triggers with poteto-mode, poteto, /poteto-mode, /poteto.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Session continuity
|
|
@@ -11,6 +11,16 @@ Preserve the user's instructions at their actual scope. Restore the saved record
|
|
|
11
11
|
and the current skill sources before work that depends on them. Keep Poteto Mode
|
|
12
12
|
and its invocation unchanged.
|
|
13
13
|
|
|
14
|
+
## Automatic activation
|
|
15
|
+
|
|
16
|
+
Each supported host runs this companion when a session starts and when it
|
|
17
|
+
resumes after compaction. With no saved record, the hook writes one holding a
|
|
18
|
+
session-scope `pstack:poteto-mode` requirement marked `automatic`, then returns
|
|
19
|
+
the current Poteto Mode source. Treat that source as the mode the user asked for
|
|
20
|
+
at session scope. A later explicit invocation writes the entry again with
|
|
21
|
+
the user's own quote and scope. An explicit deactivation stops the automatic
|
|
22
|
+
recovery for that host session.
|
|
23
|
+
|
|
14
24
|
## On activation or recovery
|
|
15
25
|
|
|
16
26
|
1. Immediately report the exact absolute record path printed by the hook.
|
|
@@ -102,13 +112,14 @@ authoritative location is restored or the user changes the requirement.
|
|
|
102
112
|
`/session-continuity off` or `Deactivate session continuity.` deactivates the
|
|
103
113
|
companion record. This does not change Poteto Mode. A later explicit Poteto or
|
|
104
114
|
companion invocation starts a new active set in the same record location.
|
|
105
|
-
Deactivated requirements stay out of recovery
|
|
115
|
+
Deactivated requirements stay out of recovery, and automatic activation stays
|
|
116
|
+
off for that host session until an explicit invocation or a `clear` arrives.
|
|
106
117
|
|
|
107
118
|
## Verification limits
|
|
108
119
|
|
|
109
|
-
Read `README.md` for setup and supported trigger forms. Claude
|
|
110
|
-
repository-tested hook adapters. Installed-host delivery, source
|
|
111
|
-
agent compliance require separate live evidence. Cursor
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
command is not evidence of automatic activation in any host.
|
|
120
|
+
Read `README.md` for setup and supported trigger forms. Claude, Codex, and
|
|
121
|
+
Cursor have repository-tested hook adapters. Installed-host delivery, source
|
|
122
|
+
loading, and agent compliance require separate live evidence. On Cursor the
|
|
123
|
+
post-compaction reload arrives with the first tool result after compaction,
|
|
124
|
+
because Cursor documents no post-compaction event that returns agent context.
|
|
125
|
+
A manual storage command is not evidence of automatic activation in any host.
|
|
@@ -7,6 +7,9 @@ const skillDirectory = dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
const hosts = new Set(['claude', 'codex', 'cursor']);
|
|
8
8
|
const potetoNames = new Set(['poteto-mode', 'pstack:poteto-mode']);
|
|
9
9
|
const companionNames = new Set(['session-continuity', 'claude-dev-env:session-continuity']);
|
|
10
|
+
const cursorEvents = new Set(['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse']);
|
|
11
|
+
const automaticQuote = 'Automatic activation configured in the installed host hook';
|
|
12
|
+
const automaticDuration = 'This host session, including its resumes and compactions';
|
|
10
13
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
11
14
|
const encode = value => JSON.stringify(value, null, 2);
|
|
12
15
|
const stateRoot = () => resolve(process.env.CDE_CONTINUITY_ROOT || join(skillDirectory, '..', '..', 'state', 'session-continuity'));
|
|
@@ -116,16 +119,31 @@ function sourceSnapshot(source) {
|
|
|
116
119
|
|
|
117
120
|
function promptEvidence(payload) {
|
|
118
121
|
const text = requireText(payload.prompt, 'user prompt');
|
|
119
|
-
return { id: digest(`${payload.turn_id || ''}\0${text}`), text, authority: 'user-message-evidence' };
|
|
122
|
+
return { id: digest(`${payload.turn_id || payload.generation_id || ''}\0${text}`), text, authority: 'user-message-evidence' };
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
function
|
|
123
|
-
|
|
125
|
+
function emptyRecord(host, session, payload) {
|
|
126
|
+
return {
|
|
124
127
|
schema: 1, host, session_id: session, status: 'active', revision: 0,
|
|
125
|
-
workspace: payload.cwd || '',
|
|
128
|
+
workspace: payload.cwd || (payload.workspace_roots || [])[0] || '',
|
|
126
129
|
task: { id: randomUUID(), goal: '', boundaries: [], constraints: [], completion: [], completed: false },
|
|
127
130
|
requirements: [], pending: [], checkpoint: { completed: [], remaining: [] },
|
|
128
131
|
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function automaticRecord(host, session, payload) {
|
|
135
|
+
const record = emptyRecord(host, session, payload);
|
|
136
|
+
record.requirements.push({
|
|
137
|
+
id: 'skill:pstack:poteto-mode', kind: 'skill', name: 'pstack:poteto-mode', active: true,
|
|
138
|
+
scope: 'session', task_id: null, duration: automaticDuration, automatic: true,
|
|
139
|
+
evidence: { prompt_id: null, quote: automaticQuote },
|
|
140
|
+
...sourceSnapshot(potetoSource()),
|
|
141
|
+
});
|
|
142
|
+
return record;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function activate(previous, host, session, payload, trigger) {
|
|
146
|
+
const record = previous?.status === 'active' ? previous : emptyRecord(host, session, payload);
|
|
129
147
|
if (record.task.completed) {
|
|
130
148
|
record.requirements.forEach(requirement => { if (requirement.scope === 'task') requirement.active = false; });
|
|
131
149
|
record.task = { id: randomUUID(), goal: '', boundaries: [], constraints: [], completion: [], completed: false };
|
|
@@ -141,7 +159,7 @@ function activate(previous, host, session, payload, trigger) {
|
|
|
141
159
|
? { source: current.source, sha256: current.sha256, comparison_text: current.comparison_text }
|
|
142
160
|
: sourceSnapshot(selectedSource);
|
|
143
161
|
const entry = {
|
|
144
|
-
id, kind: 'skill', name: 'pstack:poteto-mode', active: true,
|
|
162
|
+
id, kind: 'skill', name: 'pstack:poteto-mode', active: true, automatic: false,
|
|
145
163
|
scope, task_id: scope === 'task' ? record.task.id : null,
|
|
146
164
|
duration: trigger.scope ? trigger.text : current?.duration || 'Current task, until completed or explicitly changed by the user',
|
|
147
165
|
evidence: { prompt_id: evidence.id, quote: trigger.text },
|
|
@@ -200,8 +218,61 @@ function render(record, path, loadSources) {
|
|
|
200
218
|
return sections.join('\n\n');
|
|
201
219
|
}
|
|
202
220
|
|
|
221
|
+
const deactivationRequest = prompt => /^(?:[/$](?:claude-dev-env:)?session-continuity off|deactivate session continuity)[.!]?\s*$/i.test(prompt || '');
|
|
222
|
+
|
|
223
|
+
function sessionOpening(host, session, path, previous, payload, reset) {
|
|
224
|
+
if (!reset && previous) {
|
|
225
|
+
if (previous.status !== 'active') return {};
|
|
226
|
+
return { context: render(previous, path, true) };
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const saved = transact(host, session, () => automaticRecord(host, session, payload));
|
|
230
|
+
return { message: `Activated Poteto Mode for this session and read the record back: ${saved.path}`,
|
|
231
|
+
context: render(saved.record, saved.path, true) };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { message: `Session continuity did not activate Poteto Mode automatically: ${error.message}` };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cursorHook(payload) {
|
|
238
|
+
const event = payload.hook_event_name;
|
|
239
|
+
if (!cursorEvents.has(event) || payload.agent_id) return {};
|
|
240
|
+
const session = requireText(payload.conversation_id || payload.session_id, 'host session id');
|
|
241
|
+
const path = recordPath('cursor', session);
|
|
242
|
+
const previous = readRecord(path, 'cursor', session);
|
|
243
|
+
if (event === 'sessionStart') {
|
|
244
|
+
const opening = sessionOpening('cursor', session, path, previous, payload, false);
|
|
245
|
+
return { ...(opening.context ? { additional_context: opening.context } : {}), ...(opening.message ? { user_message: opening.message } : {}) };
|
|
246
|
+
}
|
|
247
|
+
if (event === 'preCompact') {
|
|
248
|
+
if (previous?.status !== 'active') return {};
|
|
249
|
+
transact('cursor', session, record => ({ ...record, reload_pending: true }));
|
|
250
|
+
return { user_message: 'Poteto Mode and the saved session record reload on the next tool result after this compaction.' };
|
|
251
|
+
}
|
|
252
|
+
if (event === 'postToolUse') {
|
|
253
|
+
if (previous?.status !== 'active' || !previous.reload_pending) return {};
|
|
254
|
+
const saved = transact('cursor', session, record => ({ ...record, reload_pending: false }));
|
|
255
|
+
return { additional_context: render(saved.record, saved.path, true) };
|
|
256
|
+
}
|
|
257
|
+
if (deactivationRequest(payload.prompt)) {
|
|
258
|
+
if (previous?.status !== 'active') return { continue: true };
|
|
259
|
+
const saved = transact('cursor', session, record => ({ ...record, status: 'inactive', reload_pending: false, pending: [] }));
|
|
260
|
+
return { continue: true, user_message: `Session continuity deactivated at ${saved.path}. Poteto Mode is unchanged.` };
|
|
261
|
+
}
|
|
262
|
+
const trigger = invocation(payload.prompt);
|
|
263
|
+
if (!trigger && previous?.status !== 'active') return { continue: true };
|
|
264
|
+
const saved = transact('cursor', session, record => {
|
|
265
|
+
if (trigger) return { ...activate(record, 'cursor', session, payload, trigger), reload_pending: true };
|
|
266
|
+
if (record?.status !== 'active') throw new Error('Record deactivated during prompt processing; retry');
|
|
267
|
+
const evidence = promptEvidence(payload);
|
|
268
|
+
if (!record.pending.some(requirement => requirement.id === evidence.id)) record.pending.push(evidence);
|
|
269
|
+
return record;
|
|
270
|
+
});
|
|
271
|
+
return { continue: true, user_message: `Session continuity recorded this message at ${saved.path}. Revision ${saved.record.revision}.` };
|
|
272
|
+
}
|
|
273
|
+
|
|
203
274
|
function hook(host, payload) {
|
|
204
|
-
if (host === 'cursor')
|
|
275
|
+
if (host === 'cursor') return cursorHook(payload);
|
|
205
276
|
const event = payload.hook_event_name;
|
|
206
277
|
if (payload.agent_id || (payload.role && payload.role !== 'user')) return {};
|
|
207
278
|
const accepted = ['SessionStart', 'UserPromptSubmit'];
|
|
@@ -211,16 +282,13 @@ function hook(host, payload) {
|
|
|
211
282
|
const path = recordPath(host, session);
|
|
212
283
|
const previous = readRecord(path, host, session);
|
|
213
284
|
if (event === 'SessionStart') {
|
|
214
|
-
if (payload.source === 'clear' && previous?.status === 'active') {
|
|
215
|
-
transact(host, session, record => ({ ...record, status: 'inactive', pending: [] }));
|
|
216
|
-
return { systemMessage: `Session continuity ended by clear: ${path}` };
|
|
217
|
-
}
|
|
218
285
|
const sources = host === 'claude' ? ['startup', 'resume', 'compact', 'clear', 'fork'] : ['startup', 'resume', 'compact', 'clear'];
|
|
219
286
|
if (!sources.includes(payload.source)) return {};
|
|
220
|
-
|
|
221
|
-
return {
|
|
287
|
+
const opening = sessionOpening(host, session, path, previous, payload, payload.source === 'clear');
|
|
288
|
+
return { ...(opening.message ? { systemMessage: opening.message } : {}),
|
|
289
|
+
...(opening.context ? { hookSpecificOutput: { hookEventName: event, additionalContext: opening.context } } : {}) };
|
|
222
290
|
}
|
|
223
|
-
if (
|
|
291
|
+
if (deactivationRequest(payload.prompt)) {
|
|
224
292
|
if (!previous || previous.status !== 'active') return {};
|
|
225
293
|
const saved = transact(host, session, record => ({ ...record, status: 'inactive', pending: [] }));
|
|
226
294
|
return { systemMessage: `Session continuity deactivated at ${saved.path}. Poteto Mode is unchanged.`,
|
|
@@ -364,12 +432,16 @@ try {
|
|
|
364
432
|
} catch (error) {
|
|
365
433
|
if (command === 'hook') {
|
|
366
434
|
const message = `Session continuity failed: ${error.message}`;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
435
|
+
const guidance = `${message}. Stop dependent work until recovery succeeds.`;
|
|
436
|
+
console.log(encode(cursorEvents.has(hookEvent)
|
|
437
|
+
? { ...(hookEvent === 'beforeSubmitPrompt' ? { continue: true } : {}), user_message: message,
|
|
438
|
+
...(['sessionStart', 'postToolUse'].includes(hookEvent) ? { additional_context: guidance } : {}) }
|
|
439
|
+
: {
|
|
440
|
+
...(['UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent) ? { decision: 'block', reason: message } : {}),
|
|
441
|
+
systemMessage: message,
|
|
442
|
+
...(['SessionStart', 'UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent)
|
|
443
|
+
? { hookSpecificOutput: { hookEventName: hookEvent, additionalContext: guidance } } : {}),
|
|
444
|
+
}));
|
|
373
445
|
} else console.error(error.message);
|
|
374
446
|
process.exitCode = command === 'hook' ? 0 : 1;
|
|
375
447
|
}
|
|
@@ -26,7 +26,7 @@ function fixture(t, host) {
|
|
|
26
26
|
writeFileSync(second, secondText);
|
|
27
27
|
const script = join(skill, 'continuity.mjs');
|
|
28
28
|
const configPath = join(root, host === 'claude' ? 'settings.json' : 'hooks.json');
|
|
29
|
-
writeFileSync(configPath, JSON.stringify({ hooks: continuityHookConfiguration(host, script) }));
|
|
29
|
+
writeFileSync(configPath, JSON.stringify({ ...(host === 'cursor' ? { version: 1 } : {}), hooks: continuityHookConfiguration(host, script) }));
|
|
30
30
|
const env = { ...process.env };
|
|
31
31
|
delete env.CDE_CONTINUITY_ROOT;
|
|
32
32
|
delete env.CDE_POTETO_SOURCE;
|
|
@@ -37,11 +37,11 @@ function fixture(t, host) {
|
|
|
37
37
|
return expectedStatus ? result.stderr : JSON.parse(result.stdout);
|
|
38
38
|
};
|
|
39
39
|
const fire = (event, session, fields = {}) => {
|
|
40
|
-
const payload = { hook_event_name: event,
|
|
40
|
+
const payload = { hook_event_name: event, cwd: root, ...(host === 'cursor' ? { conversation_id: session } : { session_id: session }), ...fields };
|
|
41
41
|
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
42
42
|
const groups = config.hooks[event] || [];
|
|
43
43
|
const matching = groups.filter(group => !group.matcher || new RegExp(group.matcher).test(event === 'SessionStart' ? fields.source : fields.command_name));
|
|
44
|
-
return matching.flatMap(group => group.hooks.map(handler => {
|
|
44
|
+
return matching.flatMap(group => (group.hooks || [group]).map(handler => {
|
|
45
45
|
const result = spawnSync(handler.command, { shell: true, input: JSON.stringify(payload), encoding: 'utf8', env });
|
|
46
46
|
assert.equal(result.status, 0, result.stderr);
|
|
47
47
|
return JSON.parse(result.stdout);
|
|
@@ -55,7 +55,6 @@ function fixture(t, host) {
|
|
|
55
55
|
for (const host of ['claude', 'codex']) {
|
|
56
56
|
test(`${host}: configured invocation emits complete instructions, writes and reads back, restores without a path`, t => {
|
|
57
57
|
const f = fixture(t, host);
|
|
58
|
-
assert.deepEqual(f.fire('SessionStart', 'session-a', { source: 'startup' }), [{}]);
|
|
59
58
|
assert.equal(existsSync(f.pathFor('session-a')), false);
|
|
60
59
|
const prefix = host === 'codex' ? '$pstack:poteto-mode' : '/pstack:poteto-mode';
|
|
61
60
|
const prompt = `${prefix} for this entire session\nFinish the sample task. Keep changes local. Use second-skill for this task.`;
|
|
@@ -86,7 +85,9 @@ for (const host of ['claude', 'codex']) {
|
|
|
86
85
|
assert.equal(readFileSync(f.poteto, 'utf8'), potetoText);
|
|
87
86
|
record = f.read('session-a');
|
|
88
87
|
assert.equal(record.pending.length, 0);
|
|
89
|
-
|
|
88
|
+
const unrelated = f.fire('SessionStart', 'unrelated-session', { source: 'startup' })[0].hookSpecificOutput.additionalContext;
|
|
89
|
+
assert.equal(unrelated.includes('build sample'), false);
|
|
90
|
+
assert.equal(unrelated.includes(secondText), false);
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
test(`${host}: quoting, discussion, tool output and subagent input do not activate`, t => {
|
|
@@ -247,12 +248,81 @@ test('a busy record blocks prompt processing, preserves state, and supports a sa
|
|
|
247
248
|
assert.equal(f.read('busy').pending.length, 2);
|
|
248
249
|
});
|
|
249
250
|
|
|
250
|
-
test('clear
|
|
251
|
+
test('clear drops a reused host id rather than inheriting an unrelated task', t => {
|
|
251
252
|
const f = fixture(t, 'claude');
|
|
252
|
-
f.submit('clear', '/poteto-mode for this entire session');
|
|
253
|
+
f.submit('clear', '/poteto-mode for this entire session\nKeep the sample local.');
|
|
254
|
+
const before = f.read('clear');
|
|
253
255
|
f.fire('SessionStart', 'clear', { source: 'clear' });
|
|
254
|
-
|
|
255
|
-
assert.
|
|
256
|
+
const record = f.read('clear');
|
|
257
|
+
assert.equal(record.status, 'active');
|
|
258
|
+
assert.notEqual(record.task.id, before.task.id);
|
|
259
|
+
assert.deepEqual(record.pending, []);
|
|
260
|
+
assert.deepEqual(record.requirements.map(entry => entry.id), ['skill:pstack:poteto-mode']);
|
|
261
|
+
assert.equal(record.requirements[0].automatic, true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
for (const host of ['claude', 'codex']) {
|
|
265
|
+
test(`${host}: session start and compaction activate Poteto Mode with no explicit invocation`, t => {
|
|
266
|
+
const f = fixture(t, host);
|
|
267
|
+
const started = f.fire('SessionStart', 'auto', { source: 'startup' })[0];
|
|
268
|
+
assert.ok(started.systemMessage.includes(f.pathFor('auto')));
|
|
269
|
+
assert.ok(started.hookSpecificOutput.additionalContext.includes(potetoText));
|
|
270
|
+
const record = f.read('auto');
|
|
271
|
+
assert.equal(record.requirements.length, 1);
|
|
272
|
+
assert.equal(record.requirements[0].scope, 'session');
|
|
273
|
+
assert.equal(record.requirements[0].automatic, true);
|
|
274
|
+
assert.equal(record.requirements[0].source, f.poteto);
|
|
275
|
+
assert.deepEqual(record.pending, []);
|
|
276
|
+
for (const source of ['compact', 'resume']) {
|
|
277
|
+
const later = f.fire('SessionStart', 'auto', { source })[0].hookSpecificOutput.additionalContext;
|
|
278
|
+
assert.ok(later.includes(potetoText));
|
|
279
|
+
}
|
|
280
|
+
assert.equal(f.read('auto').revision, record.revision);
|
|
281
|
+
f.submit('auto', '/session-continuity off');
|
|
282
|
+
assert.deepEqual(f.fire('SessionStart', 'auto', { source: 'compact' }), [{}]);
|
|
283
|
+
assert.deepEqual(f.fire('SessionStart', 'auto', { source: 'startup' }), [{}]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test(`${host}: an unavailable Poteto source stops automatic activation without writing a record`, t => {
|
|
287
|
+
const f = fixture(t, host);
|
|
288
|
+
writeFileSync(f.poteto, '---\nname: Something Else\n---\n');
|
|
289
|
+
const started = f.fire('SessionStart', 'broken', { source: 'startup' })[0];
|
|
290
|
+
assert.match(started.systemMessage, /did not activate Poteto Mode/);
|
|
291
|
+
assert.equal(existsSync(f.pathFor('broken')), false);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
test('cursor: session start injects Poteto Mode and compaction reloads it on the next tool result', t => {
|
|
296
|
+
const f = fixture(t, 'cursor');
|
|
297
|
+
const started = f.fire('sessionStart', 'cursor-a', { session_id: 'composer-1', composer_mode: 'agent' })[0];
|
|
298
|
+
assert.ok(started.additional_context.includes(potetoText));
|
|
299
|
+
assert.equal(f.read('cursor-a').requirements[0].automatic, true);
|
|
300
|
+
assert.deepEqual(f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' }), [{}]);
|
|
301
|
+
const compacting = f.fire('preCompact', 'cursor-a', { trigger: 'auto' })[0];
|
|
302
|
+
assert.ok(compacting.user_message.includes('Poteto Mode'));
|
|
303
|
+
assert.equal(f.read('cursor-a').reload_pending, true);
|
|
304
|
+
const reloaded = f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' })[0];
|
|
305
|
+
assert.ok(reloaded.additional_context.includes(potetoText));
|
|
306
|
+
assert.ok(reloaded.additional_context.includes(f.pathFor('cursor-a')));
|
|
307
|
+
assert.equal(f.read('cursor-a').reload_pending, false);
|
|
308
|
+
assert.deepEqual(f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' }), [{}]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('cursor: a prompt records evidence, never blocks submission, and reloads named skills after a tool result', t => {
|
|
312
|
+
const f = fixture(t, 'cursor');
|
|
313
|
+
f.fire('sessionStart', 'cursor-b', { session_id: 'composer-2' });
|
|
314
|
+
const submitted = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: 'Finish the sample task.', generation_id: 'gen-1' })[0];
|
|
315
|
+
assert.equal(submitted.continue, true);
|
|
316
|
+
assert.equal(f.read('cursor-b').pending.length, 1);
|
|
317
|
+
const invoked = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: '/poteto-mode for this entire session', generation_id: 'gen-2' })[0];
|
|
318
|
+
assert.equal(invoked.continue, true);
|
|
319
|
+
assert.equal(f.read('cursor-b').reload_pending, true);
|
|
320
|
+
assert.ok(f.fire('postToolUse', 'cursor-b', { tool_name: 'Read' })[0].additional_context.includes(potetoText));
|
|
321
|
+
const off = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: '/session-continuity off', generation_id: 'gen-3' })[0];
|
|
322
|
+
assert.equal(off.continue, true);
|
|
323
|
+
assert.equal(f.read('cursor-b').status, 'inactive');
|
|
324
|
+
assert.deepEqual(f.fire('sessionStart', 'cursor-b', { session_id: 'composer-2' }), [{}]);
|
|
325
|
+
assert.deepEqual(f.fire('preCompact', 'cursor-b', { trigger: 'manual' }), [{}]);
|
|
256
326
|
});
|
|
257
327
|
|
|
258
328
|
test('missing host identity and corrupt records produce visible recovery failures', t => {
|
|
@@ -2,13 +2,15 @@ import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } fro
|
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
+
const supportedHosts = ['claude', 'codex', 'cursor'];
|
|
6
|
+
|
|
5
7
|
export function continuityHookConfiguration(host, script) {
|
|
6
|
-
if (host
|
|
7
|
-
throw new Error('Cursor automatic activation is unsupported: beforeSubmitPrompt has no agent-context output, sessionStart is fire-and-forget, and preCompact is observational. No Cursor settings changed.');
|
|
8
|
-
}
|
|
9
|
-
if (!['claude', 'codex'].includes(host)) throw new Error(`Unsupported host ${host}`);
|
|
8
|
+
if (!supportedHosts.includes(host)) throw new Error(`Unsupported host ${host}`);
|
|
10
9
|
if (/[\r\n"`$%]/.test(script)) throw new Error('Hook script path contains shell expansion characters');
|
|
11
10
|
const command = `node "${script.replace(/\\/g, '/')}" hook ${host}`;
|
|
11
|
+
if (host === 'cursor') {
|
|
12
|
+
return Object.fromEntries(['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse'].map(event => [event, [{ command }]]));
|
|
13
|
+
}
|
|
12
14
|
const events = host === 'claude' ? ['UserPromptSubmit', 'UserPromptExpansion', 'SessionStart'] : ['UserPromptSubmit', 'SessionStart'];
|
|
13
15
|
return Object.fromEntries(events.map(event => [event, [{
|
|
14
16
|
...(event === 'SessionStart' ? { matcher: host === 'claude' ? 'startup|resume|compact|clear|fork' : 'startup|resume|compact|clear' } : {}),
|
|
@@ -20,19 +22,22 @@ export function continuityHookConfiguration(host, script) {
|
|
|
20
22
|
export function mergeContinuityHooks(existing, host, script) {
|
|
21
23
|
const additions = continuityHookConfiguration(host, script);
|
|
22
24
|
const mergedConfiguration = structuredClone(existing);
|
|
25
|
+
if (host === 'cursor') mergedConfiguration.version = 1;
|
|
23
26
|
mergedConfiguration.hooks ||= {};
|
|
24
27
|
for (const [event, groups] of Object.entries(additions)) {
|
|
25
28
|
const current = mergedConfiguration.hooks[event] || [];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const command = host === 'cursor' ? groups[0].command : groups[0].hooks[0].command;
|
|
30
|
+
for (const entry of current) {
|
|
31
|
+
for (const hook of entry.hooks || [entry]) {
|
|
32
|
+
if (hook.command?.includes('/session-continuity/continuity.mjs') && hook.command !== command) {
|
|
29
33
|
throw new Error('Another continuity installation owns this host config. Select its profile or remove that installation explicitly.');
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
|
-
const kept = current.flatMap(
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
const kept = current.flatMap(entry => {
|
|
38
|
+
if (host === 'cursor') return entry.command === command ? [] : [entry];
|
|
39
|
+
const hooks = (entry.hooks || []).filter(hook => hook.command !== command);
|
|
40
|
+
return hooks.length ? [{ ...entry, hooks }] : [];
|
|
36
41
|
});
|
|
37
42
|
mergedConfiguration.hooks[event] = kept.concat(groups);
|
|
38
43
|
}
|
|
@@ -41,15 +46,20 @@ export function mergeContinuityHooks(existing, host, script) {
|
|
|
41
46
|
|
|
42
47
|
async function main() {
|
|
43
48
|
const requested = process.argv.slice(2);
|
|
44
|
-
const selected = requested.length ? requested :
|
|
45
|
-
|
|
49
|
+
const selected = requested.length ? requested : supportedHosts;
|
|
50
|
+
const unsupported = selected.find(host => !supportedHosts.includes(host));
|
|
51
|
+
if (unsupported) throw new Error(`Unsupported host ${unsupported}`);
|
|
46
52
|
const { resolveInstallRoot } = await import('./resolve-install-root.mjs');
|
|
47
53
|
const roots = resolveInstallRoot();
|
|
48
54
|
const script = join(roots.skillsInstallDirectory, 'session-continuity', 'continuity.mjs');
|
|
49
55
|
if (!existsSync(script) || !existsSync(join(dirname(script), 'SKILL.md'))) {
|
|
50
56
|
throw new Error('Run the full claude-dev-env installer from this checkout first so the companion is in the canonical agents home.');
|
|
51
57
|
}
|
|
52
|
-
const paths = {
|
|
58
|
+
const paths = {
|
|
59
|
+
claude: join(roots.managedRoot, 'settings.json'),
|
|
60
|
+
codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json'),
|
|
61
|
+
cursor: join(roots.cursorInstallDirectory, 'hooks.json'),
|
|
62
|
+
};
|
|
53
63
|
const plans = selected.map(host => {
|
|
54
64
|
const path = paths[host];
|
|
55
65
|
if (!existsSync(dirname(path))) throw new Error(`Host config directory is absent: ${dirname(path)}`);
|
|
@@ -70,7 +80,7 @@ async function main() {
|
|
|
70
80
|
if (readFileSync(plan.path, 'utf8') !== plan.content) throw new Error(`Config read-back mismatch: ${plan.path}`);
|
|
71
81
|
console.log(`Configured and read back: ${plan.path}`);
|
|
72
82
|
}
|
|
73
|
-
console.log('Review and trust the new hooks in each host.
|
|
83
|
+
console.log('Review and trust the new hooks in each host.');
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
if (process.argv[1] && resolve(realpathSync(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
@@ -14,11 +14,24 @@ test('setup preserves other hooks and settings, is idempotent, and rejects compe
|
|
|
14
14
|
assert.throws(() => continuityHookConfiguration('claude', '/path/with$expansion/continuity.mjs'), /expansion/);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test('Cursor setup
|
|
18
|
-
const existing = {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
assert.equal(
|
|
17
|
+
test('Cursor setup keeps other hooks, sets the file version, and stays idempotent', () => {
|
|
18
|
+
const existing = { hooks: { beforeShellExecution: [{ command: 'existing' }] } };
|
|
19
|
+
const script = '/home/jon/.agents/skills/session-continuity/continuity.mjs';
|
|
20
|
+
const first = mergeContinuityHooks(existing, 'cursor', script);
|
|
21
|
+
assert.equal(first.version, 1);
|
|
22
|
+
assert.equal(first.hooks.beforeShellExecution[0].command, 'existing');
|
|
23
|
+
assert.deepEqual(mergeContinuityHooks(first, 'cursor', script), first);
|
|
24
|
+
assert.equal(existing.hooks.beforeShellExecution.length, 1);
|
|
25
|
+
assert.throws(() => mergeContinuityHooks(first, 'cursor', '/other/session-continuity/continuity.mjs'), /Another continuity installation/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('Cursor setup registers only documented context-carrying events', () => {
|
|
29
|
+
const configuration = continuityHookConfiguration('cursor', '/home/jon/.agents/skills/session-continuity/continuity.mjs');
|
|
30
|
+
assert.deepEqual(Object.keys(configuration), ['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse']);
|
|
31
|
+
for (const entries of Object.values(configuration)) {
|
|
32
|
+
assert.equal(entries.length, 1);
|
|
33
|
+
assert.equal(entries[0].command, 'node "/home/jon/.agents/skills/session-continuity/continuity.mjs" hook cursor');
|
|
34
|
+
}
|
|
22
35
|
});
|
|
23
36
|
|
|
24
37
|
for (const host of ['claude', 'codex']) {
|
package/bin/install.test.mjs
CHANGED
|
@@ -1353,6 +1353,23 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
|
|
|
1353
1353
|
});
|
|
1354
1354
|
|
|
1355
1355
|
|
|
1356
|
+
test('shipped SessionEnd hook timeouts stay within the Codex runtime limit', () => {
|
|
1357
|
+
const shippedHooksConfig = JSON.parse(
|
|
1358
|
+
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
1359
|
+
);
|
|
1360
|
+
const allSessionEndHooks = (shippedHooksConfig.hooks.SessionEnd || [])
|
|
1361
|
+
.flatMap(eachMatcherGroup => eachMatcherGroup.hooks);
|
|
1362
|
+
|
|
1363
|
+
assert.ok(allSessionEndHooks.length > 0, 'shipped hooks.json must register SessionEnd hooks');
|
|
1364
|
+
for (const eachSessionEndHook of allSessionEndHooks) {
|
|
1365
|
+
assert.ok(
|
|
1366
|
+
eachSessionEndHook.timeout <= 3,
|
|
1367
|
+
`${eachSessionEndHook.command} exceeds the Codex SessionEnd runtime limit`,
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
|
|
1356
1373
|
test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle hooks', () => {
|
|
1357
1374
|
const shippedHooksConfig = JSON.parse(
|
|
1358
1375
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Retired search hook entrypoint kept inert for stale command references."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Regression tests for the inert retired search hook entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
_ENTRYPOINT_PATH = _BLOCKING_DIRECTORY / "content_search_to_zoekt_redirector.py"
|
|
13
|
+
_HOOKS_MANIFEST_PATH = _BLOCKING_DIRECTORY.parent / "hooks.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_retired_entrypoint_allows_valid_search_events_silently() -> None:
|
|
17
|
+
all_search_events = (
|
|
18
|
+
{"tool_name": "Bash", "tool_input": {"command": "rg --files packages"}},
|
|
19
|
+
{
|
|
20
|
+
"tool_name": "Grep",
|
|
21
|
+
"tool_input": {"pattern": "zoekt", "path": "packages"},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"tool_name": "Search",
|
|
25
|
+
"tool_input": {"pattern": "zoekt", "path": "packages"},
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
for each_search_event in all_search_events:
|
|
30
|
+
completed_process = subprocess.run(
|
|
31
|
+
[sys.executable, str(_ENTRYPOINT_PATH)],
|
|
32
|
+
check=False,
|
|
33
|
+
input=json.dumps(each_search_event),
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
)
|
|
38
|
+
assert completed_process.returncode == 0
|
|
39
|
+
assert completed_process.stdout == ""
|
|
40
|
+
assert completed_process.stderr == ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_shipped_hook_manifest_omits_retired_entrypoint() -> None:
|
|
44
|
+
hooks_manifest_text = _HOOKS_MANIFEST_PATH.read_text(encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
assert _ENTRYPOINT_PATH.name not in hooks_manifest_text
|
package/hooks/hooks.json
CHANGED
|
@@ -84,12 +84,12 @@
|
|
|
84
84
|
{
|
|
85
85
|
"type": "command",
|
|
86
86
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/session_end_cleanup.py",
|
|
87
|
-
"timeout":
|
|
87
|
+
"timeout": 3
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"type": "command",
|
|
91
91
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
|
|
92
|
-
"timeout":
|
|
92
|
+
"timeout": 3
|
|
93
93
|
}
|
|
94
94
|
]
|
|
95
95
|
}
|
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ Three tools are equally sanctioned; pick by what you know:
|
|
|
19
19
|
| A name or path pattern | The harness `Glob` tool |
|
|
20
20
|
| Text inside files | The harness `Grep` tool |
|
|
21
21
|
|
|
22
|
-
When `es.exe`
|
|
22
|
+
When `es.exe` returns Error 8, retry the same search twice before any fallback. Error 8 is a missing IPC client window, not proof the index is down. After those retries fail, report that the Everything IPC client did not answer and that the service state was not probed, then fall back to `Glob` or `Grep` with the same scope. When `es.exe` is missing, or a later search returns no hits, fall back to `Glob` or `Grep`. Ask the user only after all three tools fail.
|
|
23
23
|
|
|
24
24
|
`skills/everything-search/SKILL.md` holds the full `es.exe` operator reference: `ext:`, `dm:`, `size:`, wildcards, OR/AND/NOT, output flags, and the junction and drive-mapping note.
|
|
25
25
|
|