@appchy/jarvis 0.1.36 → 0.1.39
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/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10104 -37174
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38718
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "./chunk-AKQQC5IT.mjs";
|
|
2
|
+
import "./chunk-7REP35VA.mjs";
|
|
3
|
+
import "./chunk-RRJ6KKYL.mjs";
|
|
4
|
+
import {
|
|
5
|
+
reranker
|
|
6
|
+
} from "./chunk-AYOJSS2F.mjs";
|
|
7
|
+
import "./chunk-YWSWQEJN.mjs";
|
|
8
|
+
|
|
9
|
+
// ../../packages/data/src/rerankers/local.ts
|
|
10
|
+
function localReranker(options = {}) {
|
|
11
|
+
const model = options.model ?? "Xenova/ms-marco-MiniLM-L-6-v2";
|
|
12
|
+
const dtype = options.dtype ?? "q8";
|
|
13
|
+
const pkg = "@huggingface/transformers";
|
|
14
|
+
let ready;
|
|
15
|
+
const load = async () => {
|
|
16
|
+
if (!ready) {
|
|
17
|
+
const mod = await import(pkg).catch(() => {
|
|
18
|
+
throw new Error(
|
|
19
|
+
"localReranker needs '@huggingface/transformers' \u2014 run `pnpm add @huggingface/transformers` (optional dep)."
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
ready = Promise.all([
|
|
23
|
+
mod.AutoTokenizer.from_pretrained(model),
|
|
24
|
+
mod.AutoModelForSequenceClassification.from_pretrained(model, { dtype })
|
|
25
|
+
]).then(([tokenizer, m]) => ({ tokenizer, model: m }));
|
|
26
|
+
}
|
|
27
|
+
return ready;
|
|
28
|
+
};
|
|
29
|
+
return reranker({
|
|
30
|
+
name: `local-rerank:${model}`,
|
|
31
|
+
family: "localReranker",
|
|
32
|
+
...options.limit !== void 0 ? { limit: options.limit } : {},
|
|
33
|
+
rerank: async (query, hits, opts) => {
|
|
34
|
+
if (hits.length === 0) return hits;
|
|
35
|
+
const { tokenizer, model: m } = await load();
|
|
36
|
+
const inputs = tokenizer(new Array(hits.length).fill(query), {
|
|
37
|
+
text_pair: hits.map((h) => h.text),
|
|
38
|
+
padding: true,
|
|
39
|
+
truncation: true
|
|
40
|
+
});
|
|
41
|
+
const { logits } = await m(inputs);
|
|
42
|
+
const scores = logits.sigmoid().tolist();
|
|
43
|
+
const scored = hits.map((hit, i) => ({ ...hit, score: scores[i]?.[0] ?? 0 }));
|
|
44
|
+
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
45
|
+
const topN = opts?.topN;
|
|
46
|
+
return typeof topN === "number" ? scored.slice(0, topN) : scored;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
localReranker
|
|
52
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dot,
|
|
3
|
+
normalize
|
|
4
|
+
} from "./chunk-AKQQC5IT.mjs";
|
|
5
|
+
import "./chunk-7REP35VA.mjs";
|
|
6
|
+
import "./chunk-RRJ6KKYL.mjs";
|
|
7
|
+
import {
|
|
8
|
+
store
|
|
9
|
+
} from "./chunk-AYOJSS2F.mjs";
|
|
10
|
+
import "./chunk-YWSWQEJN.mjs";
|
|
11
|
+
|
|
12
|
+
// ../../packages/data/src/stores/memory.ts
|
|
13
|
+
function memoryStore() {
|
|
14
|
+
const vectors = /* @__PURE__ */ new Map();
|
|
15
|
+
return store({
|
|
16
|
+
name: "memory",
|
|
17
|
+
family: "memoryStore",
|
|
18
|
+
add: (items) => {
|
|
19
|
+
for (const { id, vector } of items) vectors.set(id, normalize(vector));
|
|
20
|
+
},
|
|
21
|
+
query: (vector, { topK, filterIds }) => {
|
|
22
|
+
const q = normalize(vector);
|
|
23
|
+
const hits = [];
|
|
24
|
+
for (const [id, v] of vectors) {
|
|
25
|
+
if (filterIds && !filterIds.has(id)) continue;
|
|
26
|
+
hits.push({ id, score: dot(q, v) });
|
|
27
|
+
}
|
|
28
|
+
hits.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
29
|
+
return hits.slice(0, topK);
|
|
30
|
+
},
|
|
31
|
+
remove: (ids) => {
|
|
32
|
+
for (const id of ids) vectors.delete(id);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
memoryStore
|
|
38
|
+
};
|
|
@@ -5,33 +5,11 @@ import { appendFile, mkdir } from "fs/promises";
|
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
|
|
7
7
|
// src/config.ts
|
|
8
|
-
import fs from "fs";
|
|
9
8
|
import path from "path";
|
|
10
9
|
import os from "os";
|
|
11
|
-
import { fileURLToPath } from "url";
|
|
12
|
-
var __filename = fileURLToPath(import.meta.url);
|
|
13
|
-
var __dirname = path.dirname(__filename);
|
|
14
|
-
function isDev() {
|
|
15
|
-
const candidates = [
|
|
16
|
-
path.join(__dirname, "env.json"),
|
|
17
|
-
path.join(__dirname, "..", "env.json")
|
|
18
|
-
];
|
|
19
|
-
for (const envFile of candidates) {
|
|
20
|
-
try {
|
|
21
|
-
const data = JSON.parse(fs.readFileSync(envFile, "utf-8"));
|
|
22
|
-
return data.env === "development";
|
|
23
|
-
} catch {
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
function resolveConfigDir() {
|
|
29
|
-
if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;
|
|
30
|
-
const base = path.join(os.homedir(), ".jarvis");
|
|
31
|
-
return isDev() ? path.join(base, "dev") : base;
|
|
32
|
-
}
|
|
33
10
|
function configDir() {
|
|
34
|
-
|
|
11
|
+
if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;
|
|
12
|
+
return path.join(os.homedir(), ".jarvis");
|
|
35
13
|
}
|
|
36
14
|
function getHooksDir() {
|
|
37
15
|
return path.join(configDir(), "hooks");
|
|
@@ -39,9 +17,9 @@ function getHooksDir() {
|
|
|
39
17
|
|
|
40
18
|
// src/hooks/drop.ts
|
|
41
19
|
async function appendDrop(req) {
|
|
42
|
-
if (!req.sessionId || !req.
|
|
20
|
+
if (!req.sessionId || !req.type || !req.uniqId) {
|
|
43
21
|
process.stderr.write(
|
|
44
|
-
`[hook] skip ${req.
|
|
22
|
+
`[hook] skip ${req.type ?? "?"} missing field sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}
|
|
45
23
|
`
|
|
46
24
|
);
|
|
47
25
|
return;
|
|
@@ -52,7 +30,7 @@ async function appendDrop(req) {
|
|
|
52
30
|
`);
|
|
53
31
|
});
|
|
54
32
|
const envelope = {
|
|
55
|
-
|
|
33
|
+
type: req.type,
|
|
56
34
|
sessionId: req.sessionId,
|
|
57
35
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
58
36
|
uniqId: req.uniqId,
|
|
@@ -62,7 +40,7 @@ async function appendDrop(req) {
|
|
|
62
40
|
try {
|
|
63
41
|
await appendFile(file, JSON.stringify(envelope) + "\n");
|
|
64
42
|
process.stderr.write(
|
|
65
|
-
`[hook] ${req.
|
|
43
|
+
`[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}
|
|
66
44
|
`
|
|
67
45
|
);
|
|
68
46
|
} catch (err) {
|
|
@@ -99,7 +77,7 @@ async function main() {
|
|
|
99
77
|
...p.file_path ? { filePath: p.file_path } : {}
|
|
100
78
|
};
|
|
101
79
|
await appendDrop({
|
|
102
|
-
|
|
80
|
+
type: "config-change",
|
|
103
81
|
sessionId: p.session_id,
|
|
104
82
|
// ts-based uniqId — ConfigChange can fire repeatedly for the same session.
|
|
105
83
|
uniqId: `config-change-${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/config-change.ts"],"sourcesContent":["/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n kind: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n kind: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook kinds can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis[/dev]/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.kind || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.kind ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n kind: req.kind,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.kind} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\nimport { fileURLToPath } from \"url\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** Cloud WS API URL (from connect token) */\n apiUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Web app URL for browser-based auth */\n appUrl?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n apiUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Paths\n// =============================================================================\n\n// =============================================================================\n// Environment (single source of truth)\n// =============================================================================\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/** Single source of truth for dev vs prod.\n * Two locations, picked by which file is shipped:\n * Dev: apps/cli/env.json (committed, `{ \"env\": \"development\" }`)\n * Prod: apps/cli/dist/env.json (written by tsup build, `{ \"env\": \"production\" }`)\n * We try the prod-style sibling first (next to bin.js when bundled), then fall\n * back to the dev-style parent path (one up from src/config.ts). The prod bundle\n * has no `..` parent env.json, and the dev source tree has no sibling env.json,\n * so the two never collide. Safe default if neither exists: prod. */\nexport function isDev(): boolean {\n const candidates = [\n path.join(__dirname, \"env.json\"),\n path.join(__dirname, \"..\", \"env.json\"),\n ];\n for (const envFile of candidates) {\n try {\n const data = JSON.parse(fs.readFileSync(envFile, \"utf-8\"));\n return data.env === \"development\";\n } catch {\n /* try next candidate */\n }\n }\n return false;\n}\n\nconst PROD_APP_URL = \"https://jarvis.appchy.com\";\nconst DEV_APP_URL = \"http://localhost:3000\";\n\nconst PROD_WS_URL = \"wss://jarvis.appchy.com/ws\";\nconst DEV_WS_URL = \"ws://localhost:7863\";\n\n/** App URL for the current environment. */\nexport function getDefaultAppUrl(): string {\n return isDev() ? (process.env.APP_URL || DEV_APP_URL) : PROD_APP_URL;\n}\n\n/** WS hub URL for the current environment. Hardcoded per build so the prod\n * binary can never subscribe to the dev hub (or vice versa) regardless of\n * what's stored in `config.apiUrl`. Dev permits a `JARVIS_UPSTREAM_URL`\n * override for tests/CI on a non-default port; prod has no escape hatch. */\nexport function getDefaultWsUrl(): string {\n return isDev() ? (process.env.JARVIS_UPSTREAM_URL || DEV_WS_URL) : PROD_WS_URL;\n}\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** Determine config directory.\n * - JARVIS_CONFIG_DIR env var takes priority (escape hatch for CI/testing)\n * - dev → ~/.jarvis/dev/\n * - prod → ~/.jarvis/\n *\n * Called fresh on every read so test environments that set\n * `JARVIS_CONFIG_DIR` AFTER module load still see the override — caching\n * the value at module init would lock tests onto the host's real\n * `~/.jarvis/dev/` and leak production state into the in-memory infra. */\nfunction resolveConfigDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n const base = path.join(os.homedir(), \".jarvis\");\n return isDev() ? path.join(base, \"dev\") : base;\n}\n\nfunction configDir(): string {\n return resolveConfigDir();\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory.\n * `prod` → `~/.jarvis/`, `dev` → `~/.jarvis/dev/`. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Prod and dev daemons each own their own subtree so a hook installed\n * for one never feeds the other. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\nexport function loadConfig(): AgentConfig | null {\n try {\n const raw = fs.readFileSync(configFile(), \"utf-8\");\n return JSON.parse(raw) as AgentConfig;\n } catch {\n return null;\n }\n}\n\nexport function saveConfig(config: AgentConfig): void {\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.apiUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (apiUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * ConfigChange hook — drops an envelope when CC's `.claude/settings.json`\n * is edited from outside the session (file write, not slash command).\n *\n * The drop carries the new `permission_mode` so the watcher can emit a\n * `settings_changed` event and the web UI updates without waiting for the\n * next assistant turn. Slash-command mode changes (e.g. `/mode plan`)\n * still propagate via the JSONL `permission-mode` event — this hook only\n * covers settings-file edits.\n */\n\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcConfigChangeInput {\n session_id?: string;\n permission_mode?: string;\n source?: string;\n file_path?: string;\n}\n\ninterface ConfigChangeDropData {\n permissionMode?: string;\n source?: string;\n filePath?: string;\n}\n\ninstallHookTimeout();\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcConfigChangeInput>();\n if (!p?.session_id) return;\n const data: ConfigChangeDropData = {\n ...(p.permission_mode ? { permissionMode: p.permission_mode } : {}),\n ...(p.source ? { source: p.source } : {}),\n ...(p.file_path ? { filePath: p.file_path } : {}),\n };\n await appendDrop<ConfigChangeDropData>({\n kind: \"config-change\",\n sessionId: p.session_id,\n // ts-based uniqId — ConfigChange can fire repeatedly for the same session.\n uniqId: `config-change-${new Date().toISOString()}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/config-change.ts"],"sourcesContent":["/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`, never a second binary.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * ConfigChange hook — drops an envelope when CC's `.claude/settings.json`\n * is edited from outside the session (file write, not slash command).\n *\n * The drop carries the new `permission_mode` so the watcher can emit a\n * `settings_changed` event and the web UI updates without waiting for the\n * next assistant turn. Slash-command mode changes (e.g. `/mode plan`)\n * still propagate via the JSONL `permission-mode` event — this hook only\n * covers settings-file edits.\n */\n\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcConfigChangeInput {\n session_id?: string;\n permission_mode?: string;\n source?: string;\n file_path?: string;\n}\n\ninterface ConfigChangeDropData {\n permissionMode?: string;\n source?: string;\n filePath?: string;\n}\n\ninstallHookTimeout();\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcConfigChangeInput>();\n if (!p?.session_id) return;\n const data: ConfigChangeDropData = {\n ...(p.permission_mode ? { permissionMode: p.permission_mode } : {}),\n ...(p.source ? { source: p.source } : {}),\n ...(p.file_path ? { filePath: p.file_path } : {}),\n };\n await appendDrop<ConfigChangeDropData>({\n type: \"config-change\",\n sessionId: p.session_id,\n // ts-based uniqId — ConfigChange can fire repeatedly for the same session.\n uniqId: `config-change-${new Date().toISOString()}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":";;;AAcA,SAAS,YAAY,aAAa;AAClC,SAAS,YAAY;;;ACPrB,OAAO,UAAU;AACjB,OAAO,QAAQ;AA+Df,SAAS,YAAoB;AAC3B,MAAI,QAAQ,IAAI,kBAAmB,QAAO,QAAQ,IAAI;AACtD,SAAO,KAAK,KAAK,GAAG,QAAQ,GAAG,SAAS;AAC1C;AAgBO,SAAS,cAAsB;AACpC,SAAO,KAAK,KAAK,UAAU,GAAG,OAAO;AACvC;;;AD9CA,eAAsB,WAAkB,KAA8C;AACpF,MAAI,CAAC,IAAI,aAAa,CAAC,IAAI,QAAQ,CAAC,IAAI,QAAQ;AAC9C,YAAQ,OAAO;AAAA,MACb,eAAe,IAAI,QAAQ,GAAG,4BACf,CAAC,CAAC,IAAI,SAAS,WAAW,CAAC,CAAC,IAAI,MAAM;AAAA;AAAA,IACvD;AACA;AAAA,EACF;AACA,QAAM,MAAM,YAAY;AACxB,QAAM,MAAM,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ;AACnD,YAAQ,OAAO,MAAM,uBAAuB,GAAG,KAAK,GAAG;AAAA,CAAI;AAAA,EAC7D,CAAC;AACD,QAAM,WAAoC;AAAA,IACxC,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC3B,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,EACZ;AACA,QAAM,OAAO,KAAK,KAAK,GAAG,IAAI,SAAS,QAAQ;AAC/C,MAAI;AACF,UAAM,WAAW,MAAM,KAAK,UAAU,QAAQ,IAAI,IAAI;AACtD,YAAQ,OAAO;AAAA,MACb,UAAU,IAAI,IAAI,uBAAuB,IAAI,SAAS,WAAW,IAAI,MAAM;AAAA;AAAA,IAC7E;AAAA,EACF,SAAS,KAAK;AACZ,YAAQ,OAAO,MAAM,wBAAwB,IAAI,KAAK,GAAG;AAAA,CAAI;AAAA,EAC/D;AACF;AAIA,eAAsB,gBAAgE;AACpF,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,qBAAiB,SAAS,QAAQ,OAAO;AACvC,aAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,IACpE;AACA,UAAM,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AAClD,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKO,SAAS,mBAAmB,KAAK,KAAW;AACjD,QAAM,QAAQ,WAAW,MAAM,QAAQ,KAAK,CAAC,GAAG,EAAE;AAClD,QAAM,MAAM;AACd;;;AEzEA,mBAAmB;AAEnB,eAAe,OAAsB;AACnC,QAAM,IAAI,MAAM,cAAmC;AACnD,MAAI,CAAC,GAAG,WAAY;AACpB,QAAM,OAA6B;AAAA,IACjC,GAAI,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,gBAAgB,IAAI,CAAC;AAAA,IACjE,GAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,IAAI,CAAC;AAAA,IACvC,GAAI,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,IAAI,CAAC;AAAA,EACjD;AACA,QAAM,WAAiC;AAAA,IACrC,MAAM;AAAA,IACN,WAAW,EAAE;AAAA;AAAA,IAEb,QAAQ,kBAAiB,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,IACjD;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;","names":[]}
|
|
@@ -1,37 +1,124 @@
|
|
|
1
1
|
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
2
2
|
|
|
3
|
+
// src/harness.ts
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
var ENTRY = join("harness", "work.py");
|
|
9
|
+
var INTERPRETERS = ["python3", "python"];
|
|
10
|
+
var OLDEST = [3, 9];
|
|
11
|
+
var INSTALLED = ["jarvis", "work"];
|
|
12
|
+
function payload() {
|
|
13
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
for (let up = 0; up < 6; up++) {
|
|
15
|
+
if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);
|
|
16
|
+
const parent = dirname(dir);
|
|
17
|
+
if (parent === dir) break;
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function interpreter() {
|
|
23
|
+
for (const name of INTERPRETERS) {
|
|
24
|
+
const said = spawnSync(name, ["--version"], { encoding: "utf-8" });
|
|
25
|
+
if (said.status !== 0) continue;
|
|
26
|
+
const version = /(\d+)\.(\d+)/.exec(`${said.stdout}${said.stderr}`);
|
|
27
|
+
if (!version) continue;
|
|
28
|
+
const [major, minor] = [Number(version[1]), Number(version[2])];
|
|
29
|
+
if (major > OLDEST[0] || major === OLDEST[0] && minor >= OLDEST[1]) return name;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function harness() {
|
|
34
|
+
const script = payload();
|
|
35
|
+
const python = interpreter();
|
|
36
|
+
return script && python ? [python, script] : INSTALLED;
|
|
37
|
+
}
|
|
38
|
+
function editContext(req) {
|
|
39
|
+
if (!req.file || !req.sessionId) return null;
|
|
40
|
+
return say(
|
|
41
|
+
["applies", "--file", req.file, "--project", req.repo, "--session", req.sessionId],
|
|
42
|
+
2e3
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
var PATIENCE = 1e4;
|
|
46
|
+
function say(args, timeout = PATIENCE) {
|
|
47
|
+
try {
|
|
48
|
+
const [command, ...prefix] = harness();
|
|
49
|
+
const ran = spawnSync(command, [...prefix, ...args], {
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
timeout,
|
|
52
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }
|
|
53
|
+
});
|
|
54
|
+
if (ran.status !== 0) return null;
|
|
55
|
+
return ran.stdout.trim() ? ran.stdout.trimEnd() : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/hooks/applies.ts
|
|
62
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
63
|
+
import { isAbsolute, join as join2, relative, resolve } from "path";
|
|
64
|
+
var WRITES = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
65
|
+
var STATE = join2(".work", "applies");
|
|
66
|
+
function globRe(pattern) {
|
|
67
|
+
let out = "";
|
|
68
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
69
|
+
const ch = pattern.charAt(i);
|
|
70
|
+
if (ch === "*") {
|
|
71
|
+
if (pattern.charAt(i + 1) === "*") {
|
|
72
|
+
out += ".*";
|
|
73
|
+
i++;
|
|
74
|
+
} else out += "[^/]*";
|
|
75
|
+
} else if (ch === "?") out += "[^/]";
|
|
76
|
+
else out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`^${out}$`);
|
|
79
|
+
}
|
|
80
|
+
function target(repo, toolName, toolInput) {
|
|
81
|
+
if (!WRITES.has(toolName)) return null;
|
|
82
|
+
const input = toolInput ?? {};
|
|
83
|
+
const named = input.file_path ?? input.notebook_path;
|
|
84
|
+
if (typeof named !== "string" || !named) return null;
|
|
85
|
+
const rel = relative(repo, isAbsolute(named) ? named : resolve(repo, named));
|
|
86
|
+
return rel && !rel.startsWith("..") ? rel.split("\\").join("/") : null;
|
|
87
|
+
}
|
|
88
|
+
function claims(declared, file) {
|
|
89
|
+
const root = declared.replace(/\/+$/, "");
|
|
90
|
+
if (root.includes("*") || root.includes("?")) return globRe(root).test(file);
|
|
91
|
+
return file === root || file.startsWith(`${root}/`);
|
|
92
|
+
}
|
|
93
|
+
function worthAsking(repo, session, file) {
|
|
94
|
+
const root = join2(repo, STATE);
|
|
95
|
+
let declared;
|
|
96
|
+
try {
|
|
97
|
+
declared = JSON.parse(
|
|
98
|
+
readFileSync(join2(root, "map", `${session}.json`), "utf-8")
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (declared.off) return false;
|
|
104
|
+
if (!existsSync2(join2(root, "method", session))) return true;
|
|
105
|
+
const system = (declared.systems ?? []).find(
|
|
106
|
+
(each) => each.paths.some((declaredPath) => claims(declaredPath, file))
|
|
107
|
+
);
|
|
108
|
+
if (!system) return false;
|
|
109
|
+
return !existsSync2(join2(root, "system", system.name, session));
|
|
110
|
+
}
|
|
111
|
+
|
|
3
112
|
// src/hooks/drop.ts
|
|
4
113
|
import { appendFile, mkdir } from "fs/promises";
|
|
5
|
-
import { join } from "path";
|
|
114
|
+
import { join as join3 } from "path";
|
|
6
115
|
|
|
7
116
|
// src/config.ts
|
|
8
|
-
import fs from "fs";
|
|
9
117
|
import path from "path";
|
|
10
118
|
import os from "os";
|
|
11
|
-
import { fileURLToPath } from "url";
|
|
12
|
-
var __filename = fileURLToPath(import.meta.url);
|
|
13
|
-
var __dirname = path.dirname(__filename);
|
|
14
|
-
function isDev() {
|
|
15
|
-
const candidates = [
|
|
16
|
-
path.join(__dirname, "env.json"),
|
|
17
|
-
path.join(__dirname, "..", "env.json")
|
|
18
|
-
];
|
|
19
|
-
for (const envFile of candidates) {
|
|
20
|
-
try {
|
|
21
|
-
const data = JSON.parse(fs.readFileSync(envFile, "utf-8"));
|
|
22
|
-
return data.env === "development";
|
|
23
|
-
} catch {
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
function resolveConfigDir() {
|
|
29
|
-
if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;
|
|
30
|
-
const base = path.join(os.homedir(), ".jarvis");
|
|
31
|
-
return isDev() ? path.join(base, "dev") : base;
|
|
32
|
-
}
|
|
33
119
|
function configDir() {
|
|
34
|
-
|
|
120
|
+
if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;
|
|
121
|
+
return path.join(os.homedir(), ".jarvis");
|
|
35
122
|
}
|
|
36
123
|
function getHooksDir() {
|
|
37
124
|
return path.join(configDir(), "hooks");
|
|
@@ -39,9 +126,9 @@ function getHooksDir() {
|
|
|
39
126
|
|
|
40
127
|
// src/hooks/drop.ts
|
|
41
128
|
async function appendDrop(req) {
|
|
42
|
-
if (!req.sessionId || !req.
|
|
129
|
+
if (!req.sessionId || !req.type || !req.uniqId) {
|
|
43
130
|
process.stderr.write(
|
|
44
|
-
`[hook] skip ${req.
|
|
131
|
+
`[hook] skip ${req.type ?? "?"} missing field sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}
|
|
45
132
|
`
|
|
46
133
|
);
|
|
47
134
|
return;
|
|
@@ -52,17 +139,17 @@ async function appendDrop(req) {
|
|
|
52
139
|
`);
|
|
53
140
|
});
|
|
54
141
|
const envelope = {
|
|
55
|
-
|
|
142
|
+
type: req.type,
|
|
56
143
|
sessionId: req.sessionId,
|
|
57
144
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
58
145
|
uniqId: req.uniqId,
|
|
59
146
|
data: req.data
|
|
60
147
|
};
|
|
61
|
-
const file =
|
|
148
|
+
const file = join3(dir, `${req.sessionId}.jsonl`);
|
|
62
149
|
try {
|
|
63
150
|
await appendFile(file, JSON.stringify(envelope) + "\n");
|
|
64
151
|
process.stderr.write(
|
|
65
|
-
`[hook] ${req.
|
|
152
|
+
`[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}
|
|
66
153
|
`
|
|
67
154
|
);
|
|
68
155
|
} catch (err) {
|
|
@@ -89,7 +176,7 @@ function installHookTimeout(ms = 750) {
|
|
|
89
176
|
}
|
|
90
177
|
|
|
91
178
|
// src/hooks/pre-tool-use.ts
|
|
92
|
-
installHookTimeout();
|
|
179
|
+
installHookTimeout(3e3);
|
|
93
180
|
async function main() {
|
|
94
181
|
const p = await readHookStdin();
|
|
95
182
|
if (!p) return;
|
|
@@ -105,7 +192,7 @@ async function main() {
|
|
|
105
192
|
...p.cwd ? { cwd: p.cwd } : {}
|
|
106
193
|
};
|
|
107
194
|
await appendDrop({
|
|
108
|
-
|
|
195
|
+
type: "pre-tool-use",
|
|
109
196
|
sessionId,
|
|
110
197
|
uniqId: toolUseId,
|
|
111
198
|
data
|
|
@@ -115,6 +202,16 @@ async function main() {
|
|
|
115
202
|
Answering in web will take over this session in-process.
|
|
116
203
|
`
|
|
117
204
|
);
|
|
205
|
+
const repo = process.env.CLAUDE_PROJECT_DIR ?? p.cwd ?? process.cwd();
|
|
206
|
+
const file = target(repo, toolName, p.tool_input);
|
|
207
|
+
if (!file || !worthAsking(repo, sessionId, file)) return;
|
|
208
|
+
const arriving = editContext({ repo, file, sessionId });
|
|
209
|
+
if (!arriving) return;
|
|
210
|
+
const payload2 = JSON.stringify({
|
|
211
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: arriving }
|
|
212
|
+
});
|
|
213
|
+
await new Promise((flushed) => process.stdout.write(`${payload2}
|
|
214
|
+
`, () => flushed()));
|
|
118
215
|
}
|
|
119
216
|
void main().finally(() => process.exit(0));
|
|
120
217
|
//# sourceMappingURL=pre-tool-use.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/pre-tool-use.ts"],"sourcesContent":["/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n kind: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n kind: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook kinds can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis[/dev]/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.kind || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.kind ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n kind: req.kind,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.kind} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\nimport { fileURLToPath } from \"url\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** Cloud WS API URL (from connect token) */\n apiUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Web app URL for browser-based auth */\n appUrl?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n apiUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Paths\n// =============================================================================\n\n// =============================================================================\n// Environment (single source of truth)\n// =============================================================================\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/** Single source of truth for dev vs prod.\n * Two locations, picked by which file is shipped:\n * Dev: apps/cli/env.json (committed, `{ \"env\": \"development\" }`)\n * Prod: apps/cli/dist/env.json (written by tsup build, `{ \"env\": \"production\" }`)\n * We try the prod-style sibling first (next to bin.js when bundled), then fall\n * back to the dev-style parent path (one up from src/config.ts). The prod bundle\n * has no `..` parent env.json, and the dev source tree has no sibling env.json,\n * so the two never collide. Safe default if neither exists: prod. */\nexport function isDev(): boolean {\n const candidates = [\n path.join(__dirname, \"env.json\"),\n path.join(__dirname, \"..\", \"env.json\"),\n ];\n for (const envFile of candidates) {\n try {\n const data = JSON.parse(fs.readFileSync(envFile, \"utf-8\"));\n return data.env === \"development\";\n } catch {\n /* try next candidate */\n }\n }\n return false;\n}\n\nconst PROD_APP_URL = \"https://jarvis.appchy.com\";\nconst DEV_APP_URL = \"http://localhost:3000\";\n\nconst PROD_WS_URL = \"wss://jarvis.appchy.com/ws\";\nconst DEV_WS_URL = \"ws://localhost:7863\";\n\n/** App URL for the current environment. */\nexport function getDefaultAppUrl(): string {\n return isDev() ? (process.env.APP_URL || DEV_APP_URL) : PROD_APP_URL;\n}\n\n/** WS hub URL for the current environment. Hardcoded per build so the prod\n * binary can never subscribe to the dev hub (or vice versa) regardless of\n * what's stored in `config.apiUrl`. Dev permits a `JARVIS_UPSTREAM_URL`\n * override for tests/CI on a non-default port; prod has no escape hatch. */\nexport function getDefaultWsUrl(): string {\n return isDev() ? (process.env.JARVIS_UPSTREAM_URL || DEV_WS_URL) : PROD_WS_URL;\n}\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** Determine config directory.\n * - JARVIS_CONFIG_DIR env var takes priority (escape hatch for CI/testing)\n * - dev → ~/.jarvis/dev/\n * - prod → ~/.jarvis/\n *\n * Called fresh on every read so test environments that set\n * `JARVIS_CONFIG_DIR` AFTER module load still see the override — caching\n * the value at module init would lock tests onto the host's real\n * `~/.jarvis/dev/` and leak production state into the in-memory infra. */\nfunction resolveConfigDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n const base = path.join(os.homedir(), \".jarvis\");\n return isDev() ? path.join(base, \"dev\") : base;\n}\n\nfunction configDir(): string {\n return resolveConfigDir();\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory.\n * `prod` → `~/.jarvis/`, `dev` → `~/.jarvis/dev/`. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Prod and dev daemons each own their own subtree so a hook installed\n * for one never feeds the other. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\nexport function loadConfig(): AgentConfig | null {\n try {\n const raw = fs.readFileSync(configFile(), \"utf-8\");\n return JSON.parse(raw) as AgentConfig;\n } catch {\n return null;\n }\n}\n\nexport function saveConfig(config: AgentConfig): void {\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.apiUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (apiUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * PreToolUse hook — appends one envelope line to the per-session\n * hook JSONL and exits.\n *\n * CC invokes this binary before every tool use, before its own\n * permission prompt fires. We drop a tiny envelope (kind, sessionId,\n * uniqId = tool_use_id, data = {toolName, toolInput, toolUseId}) and\n * exit 0 with no output — CC's terminal permission prompt fires as\n * usual and the user can answer there.\n *\n * The daemon's hook-JSONL watcher consumes the line and upserts\n * `interaction.input` onto the matching cached `tool_call` event,\n * giving web/mobile a live gate to resolve. See:\n * apps/cli/src/sessions.ts — tailHookJsonl + applyPreToolUseDrop\n *\n * Hot-path constraints: never block, never RPC, never read user input.\n */\n\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcPreToolUseInput {\n session_id?: string;\n transcript_path?: string;\n tool_name?: string;\n tool_input?: unknown;\n tool_use_id?: string;\n cwd?: string;\n}\n\ninterface PreToolUseDropData {\n toolUseId: string;\n toolName: string;\n toolInput: unknown;\n transcriptPath?: string;\n cwd?: string;\n}\n\ninstallHookTimeout();\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcPreToolUseInput>();\n if (!p) return;\n const sessionId = p.session_id;\n const toolUseId = p.tool_use_id;\n const toolName = p.tool_name;\n if (!sessionId || !toolUseId || !toolName) return;\n\n const data: PreToolUseDropData = {\n toolUseId,\n toolName,\n toolInput: p.tool_input ?? {},\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<PreToolUseDropData>({\n kind: \"pre-tool-use\",\n sessionId,\n uniqId: toolUseId,\n data,\n });\n\n // Inform the user that web/mobile can answer this gate too. CC tees\n // hook stderr to its log; doesn't affect CC's decision flow (stdout\n // is what CC reads for permission JSON, and we don't write stdout).\n // Worth knowing: if you answer in web, the daemon promotes this\n // session to in-process — your terminal `claude` will exit.\n process.stderr.write(\n `[jarvis] ${toolName} (${toolUseId}) — also approvable from web/mobile.\\n` +\n ` Answering in web will take over this session in-process.\\n`,\n );\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/harness.ts","../../src/hooks/applies.ts","../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/pre-tool-use.ts"],"sourcesContent":["/**\n * The work harness — where it ships, and what runs it.\n *\n * The harness owns the `work/` tree's invariants: bucket-is-status, tier derivation,\n * the id allocator and the completion gate. It used to live in a Claude Code plugin,\n * which made every write to the board require one vendor's plugin to be installed —\n * so an agent that speaks MCP but is not Claude Code could read the board and could\n * not touch it. It ships inside this package now, and `jarvis work` is the one door\n * onto it.\n *\n * It is Python, and this file is the whole of what Node needs to know about that: a\n * payload directory and an interpreter. Either can be missing, and a missing one is\n * REPORTED — `harness()` still answers, so a board stays readable and a write fails\n * where writes fail. What must not happen is what used to: a raw shell error, several\n * calls after the point where anything could be done about it.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** The entry inside the payload. */\nconst ENTRY = join(\"harness\", \"work.py\");\n\n/** Interpreters to try, in order. Both names, because a machine that has Python\n * under only one of them is common enough that failing on it would be a bug. */\nconst INTERPRETERS = [\"python3\", \"python\"] as const;\n\n/**\n * The oldest Python the payload runs on, proven by running its own suite there.\n *\n * 3.9 rather than something newer on purpose: it is what macOS ships at\n * `/usr/bin/python3`, and therefore what a machine with nothing installed actually\n * has — which is precisely the machine this whole arrangement exists to serve. Two\n * annotations in the payload had to go to keep it, and that was the cheaper side of\n * the trade by a wide margin.\n */\nconst OLDEST = [3, 9] as const;\n\n/** How to run the harness: argv, up to but not including its subcommand. */\nexport type Harness = [command: string, ...args: string[]];\n\n/** What a consumer outside this repo has on its PATH, and the honest thing to\n * attempt when the payload cannot be resolved from here. */\nconst INSTALLED: Harness = [\"jarvis\", \"work\"];\n\n/**\n * The payload's entry script, or null when this build does not carry one.\n *\n * Resolved by walking up from this module rather than from `process.argv`, because\n * the answer must not depend on how the process was started: `tsx src/bin.ts`, the\n * bundled `dist/bin.js` and a re-spawned daemon all sit at different depths under\n * the same package root, and all three have to find the same files.\n */\nexport function payload(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let up = 0; up < 6; up++) {\n if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** The first interpreter on PATH new enough to run the payload, else null. */\nexport function interpreter(): string | null {\n for (const name of INTERPRETERS) {\n // The VERSION, not just an exit code. A name can exist on PATH as a stub that is\n // not a working interpreter — which is what macOS has until the command line\n // tools are installed — and an interpreter too old to parse the payload fails\n // with a traceback from inside it, which is the shape of error this whole file\n // exists to replace.\n const said = spawnSync(name, [\"--version\"], { encoding: \"utf-8\" });\n if (said.status !== 0) continue;\n const version = /(\\d+)\\.(\\d+)/.exec(`${said.stdout}${said.stderr}`);\n if (!version) continue;\n const [major, minor] = [Number(version[1]), Number(version[2])];\n if (major > OLDEST[0] || (major === OLDEST[0] && minor >= OLDEST[1])) return name;\n }\n return null;\n}\n\n/**\n * Why the harness cannot be reached from here, or null when it can.\n *\n * Phrased as what to do about it. `jarvis serve` reports this at startup, so it is\n * read by somebody who has just pointed an agent at their repo and has no other clue\n * that the board is about to refuse every write.\n */\nexport function harnessProblem(): string | null {\n if (!payload()) {\n return (\n \"this jarvis build carries no work harness — the board can be read but not \" +\n \"changed. Reinstall with `npm i -g @appchy/jarvis`.\"\n );\n }\n if (!interpreter()) {\n return (\n `the work harness needs Python ${OLDEST[0]}.${OLDEST[1]} or newer and this ` +\n \"machine has none on its PATH — the board can be read but not changed. \" +\n \"Install it, then try again.\"\n );\n }\n return null;\n}\n\n/**\n * How to run the harness.\n *\n * Never throws. A board whose harness is unreachable is still a board worth reading,\n * and refusing to build one would take the reads down with the writes; the caller\n * that needs to know asks `harnessProblem()`. The fallback names what a consumer\n * outside this repo actually has, so the failure a caller meets is a missing command\n * rather than a missing file.\n */\nexport function harness(): Harness {\n const script = payload();\n const python = interpreter();\n return script && python ? [python, script] : INSTALLED;\n}\n\n/**\n * The block a session opens with, for the repo at `repo` — or null when there is none.\n *\n * The harness composes every byte of it from that repo's own config: the board, its\n * standards, the decisions it has already taken, what each release is for. Nothing here\n * adds a word, and no caller of this may either — several doors serve these bytes now,\n * and a sentence any one of them typed itself is a sentence they could disagree about.\n *\n * **It never throws and never reports its own failure**, which is unlike everything else\n * in this file. Its first caller is a `SessionStart` hook: a message there greets somebody\n * with an error before they have typed anything, and a missing block is a far smaller harm\n * than a session that opens broken. A caller with somewhere to put a diagnosis asks\n * `harnessProblem()` for one.\n */\nexport function sessionContext(repo: string): string | null {\n return say([\"context\", \"--project\", repo]);\n}\n\n/**\n * The METHOD in full — how work is done here, on request.\n *\n * Split from {@link sessionContext} rather than folded into it, because the two are\n * asked at different moments and cost two orders of magnitude apart: the block a\n * session opens with is ~670 tokens of derived fact, and this is ~11,000 of prose. One\n * reader still, so a session cannot end up having read a different method than the\n * block told it to go and read.\n */\nexport function sessionMethod(repo: string): string | null {\n return say([\"method\", \"--project\", repo]);\n}\n\nexport interface EditContextRequest {\n repo: string;\n /** Repo-relative path the session is about to write. */\n file: string;\n /** Absent means the harness stays silent: it cannot promise once without one. */\n sessionId?: string;\n}\n\n/**\n * What a session must be told now that it is about to write this file — or null.\n *\n * The judgements no gate can catch, on the first write of a session, and which part of\n * the code it has walked into, the first time it writes under one. Both are the\n * harness's to word and to ration; this hands over a file and a session id and nothing\n * else, the same split {@link wrapReminder} uses.\n *\n * Never throws, for the reason {@link sessionContext} does not: its caller is a hook\n * firing before somebody's edit, and a hook that fails loudly interrupts them to report\n * a problem with a reminder.\n */\nexport function editContext(req: EditContextRequest): string | null {\n if (!req.file || !req.sessionId) return null;\n // Tighter than the shared ceiling: this one fires before somebody's edit, and a\n // rule that arrives seconds after the file was written has already missed.\n return say(\n [\"applies\", \"--file\", req.file, \"--project\", req.repo, \"--session\", req.sessionId],\n 2_000,\n );\n}\n\n/**\n * Everything a machine knows about finishing this session cleanly, for `repo` — or null\n * when the harness cannot answer.\n *\n * It writes nothing. What comes back is what only a machine can say: work that is not\n * in git, items the board still says somebody is on, the descriptions of the world this\n * run changed, what the tree disagrees with itself about, and the derived prompt that\n * opens the next session. The prose is the agent's to write, because a handoff is\n * judgement and a generated one reads as considered while being wrong.\n */\nexport function wrapBrief(repo: string): string | null {\n return say([\"wrap\", \"--project\", repo]);\n}\n\nexport interface WrapReminderRequest {\n repo: string;\n /** Tokens the session is holding right now. Zero or less means unknown. */\n used: number;\n sessionId?: string;\n}\n\nexport interface WrapReminderResponse {\n /** One line, for a surface that shows the reader a notice. */\n headline: string;\n /** The whole reminder, for the agent to act on. */\n note: string;\n}\n\n/**\n * Whether a session this full should be wrapping up, and what that means in `repo`.\n * Null when it should not, or when nothing can be said with confidence.\n *\n * **The measurement is the caller's and the judgement is the harness's**, and the\n * split is not arbitrary. How many tokens a session is holding is a thing only its\n * own client can answer, and every client answers it differently — while the\n * threshold, the wording, the once-a-session rule and whatever the repo adds are the\n * same whoever is asking. So this hands over a number, and a harness command that\n * took one vendor's log file is a harness that works for one vendor.\n *\n * Never throws, for the same reason {@link sessionContext} does not: its caller is a\n * hook, and a hook that fails loudly interrupts somebody mid-thought to report a\n * problem with a reminder.\n */\nexport function wrapReminder(req: WrapReminderRequest): WrapReminderResponse | null {\n if (!(req.used > 0)) return null;\n const said = say([\n \"remind\",\n \"--used\",\n String(req.used),\n \"--project\",\n req.repo,\n ...(req.sessionId ? [\"--session\", req.sessionId] : []),\n ]);\n if (!said) return null;\n try {\n const parsed = JSON.parse(said) as Partial<WrapReminderResponse>;\n if (!parsed.headline || !parsed.note) return null;\n return { headline: parsed.headline, note: parsed.note };\n } catch {\n return null;\n }\n}\n\n/**\n * How long a harness call may take before it is killed and read as \"no answer\".\n *\n * **Every caller here is a hook, and `installHookTimeout` cannot save one of them.**\n * That watchdog is a `setTimeout`, and a timer does not fire while a synchronous\n * `spawnSync` holds the thread — so an interpreter that is slow to start (a loaded\n * machine, a scanner intercepting the spawn, a repo on a network mount) blocks the\n * session for as long as it likes, past any ceiling the hook believes it set. The\n * bound has to be on the spawn itself. Generous, because being killed mid-answer is\n * the one failure worse than being slow.\n */\nconst PATIENCE = 10_000;\n\n/** What the harness said, or null — for a caller with nowhere to put a failure. */\nfunction say(args: string[], timeout = PATIENCE): string | null {\n try {\n const [command, ...prefix] = harness();\n const ran = spawnSync(command, [...prefix, ...args], {\n encoding: \"utf-8\",\n timeout,\n env: { ...process.env, PYTHONDONTWRITEBYTECODE: \"1\" },\n });\n // A killed child leaves a non-zero status (or none at all), so the timeout reads\n // as every other failure does: no answer, and nothing said about it.\n if (ran.status !== 0) return null;\n return ran.stdout.trim() ? ran.stdout.trimEnd() : null;\n } catch {\n return null;\n }\n}\n","/**\n * Deciding whether the harness could still have something to say — without asking it.\n *\n * The `PreToolUse` hook fires before EVERY tool call, and reaching the harness costs\n * roughly a quarter-second of Python start. Paying that before every edit, for an\n * answer that is almost always \"nothing\", is exactly the blocking a hook may not do.\n * So the harness leaves behind what it resolved and one marker per thing it has\n * already said, and this reads them.\n *\n * **It is a cache, not a second source.** Which globs belong to which system is the\n * harness's answer, written by the harness; what lives here is the replay, plus the\n * glob semantics needed to replay it. Those semantics are duplicated by hand in two\n * languages and `applies.test.ts` asserts the cases `test_work.py` asserts, which is\n * the only thing keeping them honest.\n *\n * **There IS a shared matcher and this deliberately does not use it.** `@jarvis/data`\n * exports one, backed by picomatch, and is already a dependency here — but importing\n * that package costs 53ms against a 1ms baseline, measured, and this module is loaded\n * before EVERY tool call, not only the writes. Two hundred tool calls a session would\n * pay ten seconds to avoid fifteen lines of regex, which is a worse trade than the one\n * this file exists to make. The semantics are not identical either: picomatch's `**`\n * matches zero segments, so a bare directory matches a glob that should only claim the\n * files under it. Reuse is the right instinct and the wrong answer on this path.\n *\n * Wrong in the generous direction on purpose: anything unreadable means ASK. Being\n * wrong that way costs one process; being wrong the other way loses the rule.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { isAbsolute, join, relative, resolve } from \"node:path\";\n\n/** The tools that put bytes on disk. A read is never the moment a rule bites, and\n * firing on one would make this the always-present notice nobody reads. */\nexport const WRITES = new Set([\"Edit\", \"Write\", \"MultiEdit\", \"NotebookEdit\"]);\n\n/** Where the harness leaves what it resolved and what it has already said.\n *\n * The default coverage root, named here rather than read from the repo's config,\n * because parsing config is the quarter-second this whole short-circuit exists to\n * avoid. A repo that moves that root loses the short-circuit and asks the harness on\n * every write instead — slower, never wrong, since the harness rations the answer\n * itself and this only decides whether asking is worth it. */\nexport const STATE = join(\".work\", \"applies\");\n\ninterface DeclaredSystems {\n systems?: { name: string; paths: string[] }[];\n /** The repo switched this off. Said explicitly rather than shown by a spent claim,\n * so a repo that wants none of this stops being asked without being told it has\n * already been given something it never was. */\n off?: boolean;\n}\n\n/**\n * A declared glob as a regex, matching the harness's own reader: `*` stops at a\n * separator and only `**` spans them.\n *\n * `fnmatch`-style matching, where `*` crosses a `/`, would let `packages/*/src` claim\n * `packages/a/b/src` and a system would swallow its neighbours.\n */\nexport function globRe(pattern: string): RegExp {\n let out = \"\";\n for (let i = 0; i < pattern.length; i++) {\n // `charAt` rather than an index: under the strict index checks this package\n // compiles with, `pattern[i]` is `string | undefined` and this never is.\n const ch = pattern.charAt(i);\n if (ch === \"*\") {\n if (pattern.charAt(i + 1) === \"*\") {\n out += \".*\";\n i++;\n } else out += \"[^/]*\";\n } else if (ch === \"?\") out += \"[^/]\";\n else out += ch.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n return new RegExp(`^${out}$`);\n}\n\n/** The file a write is about to touch, repo-relative, or null when this is not a\n * write — or is one aimed outside the repo, which no system can claim. */\nexport function target(repo: string, toolName: string, toolInput: unknown): string | null {\n if (!WRITES.has(toolName)) return null;\n const input = (toolInput ?? {}) as { file_path?: unknown; notebook_path?: unknown };\n const named = input.file_path ?? input.notebook_path;\n if (typeof named !== \"string\" || !named) return null;\n const rel = relative(repo, isAbsolute(named) ? named : resolve(repo, named));\n return rel && !rel.startsWith(\"..\") ? rel.split(\"\\\\\").join(\"/\") : null;\n}\n\n/**\n * Whether a system's declaration covers this file.\n *\n * **Two forms are in use across these repos and both are meant.** Anything carrying\n * `*` or `?` is a glob and is matched as one. A bare path is what a document writes\n * when it names which part of a repo it is about, and `packages/mcp` there means that\n * directory's contents — not a file literally called `mcp`, which is all a glob\n * matcher would give back. The harness reader draws the same line; `applies.test.ts`\n * and `test_work.py` assert the same cases on either side of it.\n */\nexport function claims(declared: string, file: string): boolean {\n const root = declared.replace(/\\/+$/, \"\");\n if (root.includes(\"*\") || root.includes(\"?\")) return globRe(root).test(file);\n return file === root || file.startsWith(`${root}/`);\n}\n\n/** Whether the harness could still have something to say about this file. */\nexport function worthAsking(repo: string, session: string, file: string): boolean {\n const root = join(repo, STATE);\n let declared: DeclaredSystems;\n try {\n declared = JSON.parse(\n readFileSync(join(root, \"map\", `${session}.json`), \"utf-8\"),\n ) as DeclaredSystems;\n } catch {\n return true; // nothing left behind yet — this is the session's first write\n }\n if (declared.off) return false;\n if (!existsSync(join(root, \"method\", session))) return true;\n const system = (declared.systems ?? []).find((each) =>\n each.paths.some((declaredPath) => claims(declaredPath, file)),\n );\n if (!system) return false;\n return !existsSync(join(root, \"system\", system.name, session));\n}\n","/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`, never a second binary.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * PreToolUse hook — two jobs, and the second must not cost the first.\n *\n * It appends one envelope line to the per-session hook JSONL so web/mobile get a live\n * gate to resolve, exactly as it always has. And when the session is about to WRITE a\n * file, it asks the harness whether anything has to arrive first — the judgements no\n * gate can catch, and which part of the code this file is in.\n *\n * CC invokes this binary before every tool use, before its own permission prompt fires.\n * The envelope (type, sessionId, uniqId = tool_use_id, data = {toolName, toolInput,\n * toolUseId}) is consumed by the daemon's hook-JSONL watcher, which upserts\n * `interaction.input` onto the matching cached `tool_call` event. See:\n * apps/cli/src/sessions.ts — tailHookJsonl + applyPreToolUseDrop\n *\n * **Why a rule arrives here rather than only at session start.** A rule injected at the\n * top of a session can be forgotten a hundred thousand tokens later; one injected as the\n * file is about to be written cannot. The session block carries every rule's NAME, so\n * repeating those here would be wallpaper — what arrives is what the block does not\n * carry, and it arrives once.\n *\n * Hot-path constraints: never block, never RPC, never read user input. The harness costs\n * roughly a quarter-second to start, which is not a price to pay before every edit, so\n * the decision to ask is made HERE from a small file the harness leaves behind: the\n * declarations it resolved, and one marker per thing already said. Steady state is a\n * read and a regex; a process is spawned only when something is genuinely unclaimed.\n */\n\nimport { editContext } from \"../harness\";\nimport { target, worthAsking } from \"./applies\";\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcPreToolUseInput {\n session_id?: string;\n transcript_path?: string;\n tool_name?: string;\n tool_input?: unknown;\n tool_use_id?: string;\n cwd?: string;\n}\n\ninterface PreToolUseDropData {\n toolUseId: string;\n toolName: string;\n toolInput: unknown;\n transcriptPath?: string;\n cwd?: string;\n}\n\n// Longer than a hook that only appends a line, because on the few fires that DO reach\n// the harness the deadline can cost the one moment this exists for. The fast path still\n// exits in about a millisecond, so this ceiling is only ever reached by a fire that was\n// always going to spawn a process.\ninstallHookTimeout(3_000);\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcPreToolUseInput>();\n if (!p) return;\n const sessionId = p.session_id;\n const toolUseId = p.tool_use_id;\n const toolName = p.tool_name;\n if (!sessionId || !toolUseId || !toolName) return;\n\n const data: PreToolUseDropData = {\n toolUseId,\n toolName,\n toolInput: p.tool_input ?? {},\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<PreToolUseDropData>({\n type: \"pre-tool-use\",\n sessionId,\n uniqId: toolUseId,\n data,\n });\n\n // Inform the user that web/mobile can answer this gate too. CC tees hook stderr to\n // its log; doesn't affect CC's decision flow. Worth knowing: if you answer in web,\n // the daemon promotes this session to in-process — your terminal `claude` will exit.\n process.stderr.write(\n `[jarvis] ${toolName} (${toolUseId}) — also approvable from web/mobile.\\n` +\n ` Answering in web will take over this session in-process.\\n`,\n );\n\n // Read from the environment first: CC sets it to the project ROOT, while `cwd` is\n // wherever the session happens to have been opened.\n const repo = process.env.CLAUDE_PROJECT_DIR ?? p.cwd ?? process.cwd();\n const file = target(repo, toolName, p.tool_input);\n if (!file || !worthAsking(repo, sessionId, file)) return;\n\n const arriving = editContext({ repo, file, sessionId });\n if (!arriving) return;\n\n // Context only — no `permissionDecision`, so CC's own permission flow runs exactly as\n // it did before. This hook has never had an opinion about whether a tool may run and\n // is not acquiring one here.\n // Awaited, not fired and forgotten. `process.exit` below does not drain a pending\n // stdout write, and a piped stdout under backpressure would drop the one payload\n // this whole hook exists to deliver — silently, which is the worst way to lose it.\n const payload = JSON.stringify({\n hookSpecificOutput: { hookEventName: \"PreToolUse\", additionalContext: arriving },\n });\n await new Promise<void>((flushed) => process.stdout.write(`${payload}\\n`, () => flushed()));\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":"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","names":["existsSync","join","join","join","payload"]}
|