@keepsnappy/agent 0.1.0-alpha.23 → 0.1.0-alpha.24
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 +6 -4
- package/dist/appium-driver.js +33 -7
- package/dist/keepsnappy.js +13641 -11240
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,12 +223,12 @@ What it does, in order:
|
|
|
223
223
|
1. Reads the investigation package with the same client as the MCP tool `get_investigation` (stored results only).
|
|
224
224
|
2. Checks that the commit ids in the package are 7–40 lowercase hex characters (anything else is refused before git is called), then creates a separate worktree of your repo (`git worktree add --detach`, in the system temp directory) at the commit the finding was measured at: the PR's commit, or main's commit for a main finding. Every git call the CLI makes on your repo runs with `-c core.hooksPath=/dev/null`, so the repo's hooks (a `post-checkout` that installs or generates things) never run for a PR's commit. Your working copy, branch and uncommitted files are never touched. The worktree is removed at the end, also after a failure, a timeout or Ctrl-C. The commit must be in your clone (`git fetch` first when it says it is not).
|
|
225
225
|
3. Computes the range itself, because the agent has no shell: `git log` and `git diff` (fixed arguments, `--no-ext-diff --no-textconv`, output capped: log 20 KB, stat 20 KB, diff 100 KB, with a note when cut) from main's commit to the measured commit, put in the prompt after the package. Without a main commit in the package, or with one your clone does not have, the prompt says so.
|
|
226
|
-
3b. **Checks for a false alarm first.** The area is the files the package names for the finding (the components' files, and the files in the place ids' screen, parent and target), kept only if they are plain repo-relative paths that exist at the measured commit. The CLI computes, with fixed arguments, hooks off, literal pathspecs after `--`, from the two measured trees (`git diff <main commit> <measured commit>`, not the PR's range since the fork: main may have changed the area after it, and then the PR builds the old code): a symmetric log (`<` a commit only main has, `>` one only the PR has), the stat and a capped diff (40 KB) of those files, and says `AREA DIFF EMPTY: yes | no | unknown`. It also reads the stored facts through the client the tools use (`main_sessions`, `main_vs_previous`, `place_trend`, and the package's noisy marks, threshold, measured-differently and setup notes, at most 12 KB) and puts them in the prompt as the false-alarm facts, so claude and codex see the same ones. If the area diff is empty
|
|
227
|
-
4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-
|
|
226
|
+
3b. **Checks for a false alarm first.** The area is the files the package names for the finding (the components' files, and the files in the place ids' screen, parent and target), kept only if they are plain repo-relative paths that exist at the measured commit. The CLI computes, with fixed arguments, hooks off, literal pathspecs after `--`, from the two measured trees (`git diff <main commit> <measured commit>`, not the PR's range since the fork: main may have changed the area after it, and then the PR builds the old code): a symmetric log (`<` a commit only main has, `>` one only the PR has), the stat and a capped diff (40 KB) of those files, and says `AREA DIFF EMPTY: yes | no | unknown`. It also reads the stored facts through the client the tools use (`main_sessions`, `main_vs_previous`, `place_trend`, and the package's noisy marks, threshold, measured-differently and setup notes, at most 12 KB) and puts them in the prompt as the false-alarm facts, so claude and codex see the same ones. If the area diff is empty and no cause is found, the answer is one `kind: no-area-change` ("likely false alarm") hypothesis with `checked`; with a cause, the cause carries the fact as one short evidence line, and the schema refuses a cause and a `no-area-change` in one answer (the repair turn fixes it). The CLI leaves out a false alarm its own check does not support (the area changed, could not be checked, another range or file) with its reason; when nothing is left it submits nothing. Rows stored by an earlier prompt that hold both are listed by `keepsnappy hypotheses` as the cause with the false alarm folded in as one line. The server cannot check this: a `no-area-change` from the skill path (or any key holder) is stored on the agent's word. The last line of the log is `KEEPSNAPPY-STATUS <word> [n]`, which `keepsnappy watch` reads for its second notification.
|
|
227
|
+
4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-v7`, the same rules as the skill) and the package as the answer layout prints it. The prompt goes on stdin, so a large package does not hit the argument limit. Nothing the user or the repo configures can widen what the agent may do:
|
|
228
228
|
- `claude -p --output-format stream-json --verbose --max-turns N --restricted --permission-mode dontAsk --permission-prompts none --tools Read,Grep,Glob --allowedTools <Read,Grep,Glob + the KeepSnappy read tools by name> --disallowedTools Bash,PowerShell,Edit,Write,… --disable-slash-commands --mcp-config <private file> --strict-mcp-config`. Which flag closes which hole: `--restricted` ignores the user's, the project's (a PR's committed `.claude/settings.json`, with its hooks and allow rules) and the local settings files and removes Bash; `--permission-mode dontAsk` and `--permission-prompts none` deny whatever is not allowed and ask nobody; `--tools` and `--allowedTools` give it Read, Grep, Glob and the read-only KeepSnappy tools only (`get_investigation`, `get_finding`, `list_findings`, `list_problems`, `compare_runs`, `window_history`, `commits_between`, `session_places`, `place_trend`, `main_sessions`, `main_changes`, `main_vs_previous`, `pr_status`, `explain_layers`; never `submit_hypotheses`, so a dry run cannot store, and never `recompute_with_new_rules`; no wildcard); `--strict-mcp-config` keeps your other MCP servers out; `--disable-slash-commands` keeps the repo's and your skills and commands out. The KeepSnappy key is in a mode 0600 file that is deleted afterwards, not in the arguments or the agent's environment. It relies on the `result`, `is_error` and `subtype` fields of the last `result` event of the stream (the stream is read line by line; the events before it are where a run that never ends has its usage). All these flags are in `claude --help` of 2.1.308 (`--max-turns` is not listed there; the prompt's own tool-call budget does not depend on it). The repo's CLAUDE.md can still be read as text by the agent; it can run and write nothing.
|
|
229
229
|
- `codex exec --json [-m <codexModel>] --ignore-user-config --ignore-rules --ephemeral -c default_permissions="ks" -c 'permissions.ks.filesystem={":minimal"="read","<worktree>"="read","/private/tmp"="none","/tmp"="none"}' -c approval_policy="never" -c project_doc_max_bytes=0 --color never -o <file> -`. **Codex's read scope is the worktree and the system minimum, with `/tmp` and `/private/tmp` denied by name (the minimum includes `/tmp`, which holds other tools' output); its shell can write nothing and reach no network, and git cannot run in it, so the prompt carries the log, stat and diff.** The permission profile replaces `--sandbox read-only`, which reads every file you can (`~/.zshenv`, `~/.ssh`): with it, a PR's text could make codex quote a secret into a hypothesis that is then stored on the server. Checked with `codex sandbox` and the same `-c` values (no model; the test `investigate.test.ts` runs it when `codex` is installed): the worktree reads (also under `/tmp`); a sibling file, `/tmp` and the home directory do not. **Not checked: that `codex exec` applies the profile the same way in a live run** (that needs a model). `project_doc_max_bytes=0` keeps the repo's `AGENTS.md` out of the prompt (checked with `codex debug prompt-input`). Whether `codex exec --ignore-user-config` still loads a repo's `.codex/config.toml` or hooks is not checked against a live run either; trust for a project lives in the user config it ignores. `--ignore-user-config` keeps your configured MCP servers and approval reviewer out.
|
|
230
230
|
5. Budget: `--max-turns` (default 26: 25 tool calls and the answer) and `--max-minutes` (default 15). At the time limit the agent and every process it started are killed, and it says `no result within N min`. More than 5 MB of output stops it too.
|
|
231
|
-
6. Validates the answer with the one hypothesis schema (`@keepsnappy/dashboard-contract/hypotheses`), after filling `runner: cli`, the agent (the model name claude reports, else `claude` / `codex`), `prompt_version` and the package's `package_digest`. An invalid answer is shown with the schema's paths and is not submitted. An answer of no hypothesis stores nothing.
|
|
231
|
+
6. Validates the answer with the one hypothesis schema (`@keepsnappy/dashboard-contract/hypotheses`), after filling `runner: cli`, the agent (the model name claude reports, else `claude` / `codex`), `prompt_version` and the package's `package_digest`. An invalid answer is shown with the schema's paths and is not submitted; before that the agent gets **one repair turn** (a second, short run: at most 2 turns and 5 minutes, same read-only tools) in which it is sent the exact errors, the limits and its refused answer, and the corrected answer goes through the same schema. The tokens of both runs are in the usage. Still refused, not JSON, or no answer: the run ends as refused (`outcome: invalid`, `KEEPSNAPPY-STATUS schema`). The refused answers (and an answer that is no JSON) are kept as `<output.dir>/investigate/<finding-id>-<time>.json` (default `keepsnappy-out`, mode 0600, the agent's text and the errors only; never uploaded); the path is in the log and in `--json` as `rejected_answer`. An answer of no hypothesis stores nothing.
|
|
232
232
|
7. Submits with the same client as the MCP tool `submit_hypotheses` and prints `N hypotheses, open`, then each one: component, files, expected change. `--json` prints one document `{ finding_id, outcome, submission, stored, usage }` (`stored` is null on a dry run). Progress and errors go to stderr.
|
|
233
233
|
8. **Records what the run used**, for every end of a run that started the agent (see "What a run used" below), and ends with one line: `used 68k tokens (~$0.07, agent reported ~$0.07)`.
|
|
234
234
|
|
|
@@ -292,6 +292,8 @@ investigate:
|
|
|
292
292
|
|
|
293
293
|
Where the agent is: `--agent claude|codex` picks it (default claude), then `--claude-path FILE` / `--codex-path FILE` (keys `investigate.claudePath` / `investigate.codexPath`) say which executable runs; without a path the name is looked up on `PATH`, as before. Order: flag, then `keepsnappy.yml` (or `--config FILE`), then your own config file, then `PATH`. `~/` is your home. Before anything starts (before the worktree, before the API is asked) the path of the agent in use must exist, be a regular file (symlinks are followed) and be executable by you; if not, the command stops with `--claude-path /x/claude: not found` (or `not a regular file`, `not executable`), or `investigate.claudePath /x/claude: not found (in <file>)` when the path came from a file, and exits 2. The path of the agent that is not in use is not looked at. The Mac app sets these from its settings.
|
|
294
294
|
|
|
295
|
+
Which account the agent runs on: `--claude-config-dir DIR` / `--codex-home DIR` (keys `investigate.claudeConfigDir` / `investigate.codexHome`, same order as the path: flag, `keepsnappy.yml` or `--config`, your config file) start the agent with `CLAUDE_CONFIG_DIR` / `CODEX_HOME` set to that folder, so an investigation uses the login kept there instead of the default one. The folder must exist (`--claude-config-dir /x: not found`, or `investigate.claudeConfigDir /x: not found (in <file>)`, exit 2); `~/` is your home; only the agent in use is looked at. `--preflight` asks `--version` the same way, `keepsnappy doctor` shows the folder in its `agent` and `agent-login` steps and asks `claude auth status` / `codex login status` with it set, and `--save` keeps it. Not set: nothing is added to the agent's environment, so a wrapper script used as `claudePath` that sets `CLAUDE_CONFIG_DIR` itself keeps working. `keepsnappy watch` forwards both flags; its `--auto-on-worse` runs `keepsnappy investigate --agent claude`, which reads `claudePath` and `claudeConfigDir` from the forwarded `--config` and from your config file.
|
|
296
|
+
|
|
295
297
|
Your own config file is `$XDG_CONFIG_HOME/keepsnappy/config.yml`, else `~/.config/keepsnappy/config.yml` (created on first write: directory 0700, file 0600). It has the same `investigate:` section as `keepsnappy.yml`; `keepsnappy investigate` and `keepsnappy watch` read it, a project's `keepsnappy.yml` (or `--config FILE`) wins over it key by key:
|
|
296
298
|
|
|
297
299
|
```sh
|
|
@@ -303,7 +305,7 @@ keepsnappy config list # the thre
|
|
|
303
305
|
keepsnappy config set investigate.codexPath /opt/codex/bin/codex --config ./my.yml # --config FILE: another file instead of your own
|
|
304
306
|
```
|
|
305
307
|
|
|
306
|
-
`set` and `unset` accept only `investigate.agent`, `investigate.claudePath`, `investigate.codexPath` and `investigate.repos.<app>` (the folder of an app's git repository; it must exist; anything else is refused with the list) and edit the file line by line, so comments, other keys and other sections stay as they are (a key written in quotes, `"tapin":`, is the same key as `tapin:` and keeps its quotes; a `repos` map left without an entry is removed with `unset`); a file that is not valid YAML, or whose `investigate:` is written inline (`investigate: {}`), is refused and left untouched. `keepsnappy investigate --save` does the same `set` for the agent and the path that run used (only those; the path only when one was given by a flag or a file, not when `PATH` found the agent).
|
|
308
|
+
`set` and `unset` accept only `investigate.agent`, `investigate.claudePath`, `investigate.codexPath`, `investigate.claudeConfigDir`, `investigate.codexHome` (an account folder; it must exist; stored absolute, `~/` stays) and `investigate.repos.<app>` (the folder of an app's git repository; it must exist; anything else is refused with the list) and edit the file line by line, so comments, other keys and other sections stay as they are (a key written in quotes, `"tapin":`, is the same key as `tapin:` and keeps its quotes; a `repos` map left without an entry is removed with `unset`); a file that is not valid YAML, or whose `investigate:` is written inline (`investigate: {}`), is refused and left untouched. `keepsnappy investigate --save` does the same `set` for the agent and the path that run used (only those; the path only when one was given by a flag or a file, not when `PATH` found the agent).
|
|
307
309
|
|
|
308
310
|
Exit codes: 0 stored (or a dry run, or no hypothesis), 1 no usable answer (timeout, agent error, invalid output), 2 wrong flags or settings, 130 Ctrl-C. Needs a key with read scope, like the MCP tools.
|
|
309
311
|
|
package/dist/appium-driver.js
CHANGED
|
@@ -428,8 +428,17 @@ class Driver {
|
|
|
428
428
|
return r.x >= 0 && r.y >= 0 && r.x + r.width <= w.width && r.y + r.height <= w.height;
|
|
429
429
|
return r.x < w.width && r.y < w.height && r.x + r.width > 0 && r.y + r.height > 0;
|
|
430
430
|
}
|
|
431
|
-
async findShown(sel, full = false) {
|
|
432
|
-
|
|
431
|
+
async findShown(sel, full = false, under, anyShown = false) {
|
|
432
|
+
try {
|
|
433
|
+
return await this.findShownOnce(sel, full, under, anyShown);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
if (sel.childOf === undefined || !(e instanceof WdError) || e.wdError !== "stale element reference")
|
|
436
|
+
throw e;
|
|
437
|
+
return this.findShownOnce(sel, full, under, anyShown);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async findShownOnce(sel, full, under, anyShown) {
|
|
441
|
+
let parent = under;
|
|
433
442
|
if (sel.childOf !== undefined) {
|
|
434
443
|
const p = await this.findShown(id(sel.childOf));
|
|
435
444
|
if (!p)
|
|
@@ -445,7 +454,7 @@ class Driver {
|
|
|
445
454
|
return first;
|
|
446
455
|
}
|
|
447
456
|
const all = await this.findAll(using, value, parent);
|
|
448
|
-
const probed = all.length > PROBE_FROM ? await this.probeShown(all, full) : undefined;
|
|
457
|
+
const probed = all.length > PROBE_FROM ? await this.probeShown(all, full, anyShown) : undefined;
|
|
449
458
|
if (probed)
|
|
450
459
|
return probed;
|
|
451
460
|
for (const el of all) {
|
|
@@ -455,7 +464,7 @@ class Driver {
|
|
|
455
464
|
}
|
|
456
465
|
return null;
|
|
457
466
|
}
|
|
458
|
-
async probeShown(els, full) {
|
|
467
|
+
async probeShown(els, full, anyShown = false) {
|
|
459
468
|
const w = await this.screen();
|
|
460
469
|
let lo = 0;
|
|
461
470
|
let hi = els.length - 1;
|
|
@@ -463,6 +472,8 @@ class Driver {
|
|
|
463
472
|
const mid = lo + hi >> 1;
|
|
464
473
|
const el = els[mid];
|
|
465
474
|
if (await this.onScreen(el, full)) {
|
|
475
|
+
if (anyShown)
|
|
476
|
+
return el;
|
|
466
477
|
let first = mid;
|
|
467
478
|
while (first > 0 && await this.onScreen(els[first - 1], full))
|
|
468
479
|
first--;
|
|
@@ -1051,7 +1062,7 @@ async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn ro
|
|
|
1051
1062
|
}
|
|
1052
1063
|
async function firstDrawn(t, row, listEl, field, scopedOrField) {
|
|
1053
1064
|
if (!scopedOrField) {
|
|
1054
|
-
return t.driver.findShown(toSelector(row));
|
|
1065
|
+
return t.driver.findShown(toSelector(row), false, undefined, true);
|
|
1055
1066
|
}
|
|
1056
1067
|
const { childOf: _list, ...inList } = toSelector(row);
|
|
1057
1068
|
const all = t.driver.platform.locators(inList);
|
|
@@ -1129,7 +1140,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
1129
1140
|
import { fileURLToPath } from "node:url";
|
|
1130
1141
|
var DRIVER_NAME = "appium";
|
|
1131
1142
|
function driverVersion() {
|
|
1132
|
-
const built = "0.1.0-alpha.
|
|
1143
|
+
const built = "0.1.0-alpha.24";
|
|
1133
1144
|
if (built)
|
|
1134
1145
|
return built;
|
|
1135
1146
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1259,6 +1270,8 @@ var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
|
|
|
1259
1270
|
var FLUSH_POLL_MS = 500;
|
|
1260
1271
|
var FLUSH_RESEND_MS = 90000;
|
|
1261
1272
|
var ACTIVATION_SETTLE_MS = 2000;
|
|
1273
|
+
var ACTIVATION_CHECK_N = 999999;
|
|
1274
|
+
var HOME_READY_MS = 30000;
|
|
1262
1275
|
var FLUSH_NOT_ACKNOWLEDGED = "not acknowledged (re-sent once)";
|
|
1263
1276
|
var flushLoss = (detail) => {
|
|
1264
1277
|
if (!detail)
|
|
@@ -1737,11 +1750,24 @@ class Session {
|
|
|
1737
1750
|
this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry, navStuck: this.defects.navStuck, navCatchUp: this.defects.navCatchUp };
|
|
1738
1751
|
this.pendingReady = null;
|
|
1739
1752
|
await this.relaunch();
|
|
1740
|
-
await this.waitVisible(home,
|
|
1753
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1741
1754
|
const alert = await this.openLink(activationLink(this.scheme, token, args));
|
|
1742
1755
|
await this.driver.sleep(ACTIVATION_SETTLE_MS);
|
|
1756
|
+
await this.relaunch();
|
|
1757
|
+
await this.waitVisible(home, HOME_READY_MS);
|
|
1758
|
+
if (token && this.linkState.linkMode !== "safari")
|
|
1759
|
+
await this.confirmActive();
|
|
1743
1760
|
return alert;
|
|
1744
1761
|
}
|
|
1762
|
+
async confirmActive() {
|
|
1763
|
+
const n = ACTIVATION_CHECK_N;
|
|
1764
|
+
const start = this.now();
|
|
1765
|
+
const answer = await this.driver.platform.memReadCommand(this.driver, n, this.scheme, this.linkState).catch(() => null);
|
|
1766
|
+
const ok = answer?.startsWith(`memread:${n} `) ?? false;
|
|
1767
|
+
this.driver.log.push({ ...this.driver.context, cmd: "activation-check", t: this.now(), ms: this.now() - start, status: ok ? 0 : 1, ...answer !== null ? { answer } : {} });
|
|
1768
|
+
if (!ok)
|
|
1769
|
+
throw new Error(`SDK did not activate: the launch after the activation link answered no command (memread:${n}${answer === null ? ", no answer" : `, got ${answer}`})`);
|
|
1770
|
+
}
|
|
1745
1771
|
opened = new Set;
|
|
1746
1772
|
told = new Set;
|
|
1747
1773
|
runFrom = null;
|