tokentrax-agent 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Greene
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # tokentrax-agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The remote half of [TokenTrax](https://github.com/jeshuwah/TokenTrax). It runs
4
+ on any machine with a context-mode-enabled AI harness — pi, Claude Code,
5
+ Cursor, Codex, Gemini CLI, and 13 others — reads that machine's session
6
+ data, and reports it to a central TokenTrax dashboard.
7
+
8
+ The dashboard then shows one machine list with every machine's sessions,
9
+ tokens, cost and context-reduction percentage.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g tokentrax-agent
15
+ tokentrax-agent setup --server http://your-dashboard:7680 --key <sync-key>
16
+ ```
17
+
18
+ `setup` does the rest: it writes the configuration, checks that the native
19
+ SQLite binding actually loads, installs a systemd user service and timer,
20
+ and enables the timer. The machine now reports its usage every 5 minutes.
21
+
22
+ No npm? Bootstrap straight from the repository:
23
+
24
+ ```bash
25
+ curl -fsSL https://raw.githubusercontent.com/jeshuwah/TokenTrax/main/agent/install.sh \
26
+ | bash -s -- --server http://your-dashboard:7680 --key <sync-key>
27
+ ```
28
+
29
+ Requires Node.js 20 or newer.
30
+
31
+ ### If the SQLite binding will not load
32
+
33
+ `better-sqlite3` is a native module. npm 12 and newer block dependency
34
+ install scripts by default, so the binding is never fetched or built — and
35
+ `require()` succeeds anyway, so the failure only shows up when a database is
36
+ opened.
37
+
38
+ ```bash
39
+ npm install-scripts approve better-sqlite3
40
+ npm rebuild better-sqlite3
41
+ ```
42
+
43
+ `tokentrax-agent setup` checks for this and tells you if it finds it.
44
+
45
+ ## Commands
46
+
47
+ | Command | What it does |
48
+ | --- | --- |
49
+ | `setup` | Configure the machine, install the timer, enable it. |
50
+ | `sync` | Send new sessions to the server. `--auto` never fails the caller. |
51
+ | `status` | Show the current configuration, the machine id and the cursor. |
52
+ | `init` | Install post-session hooks (harnesses that support them). |
53
+ | `uninstall` | Remove post-session hooks. |
54
+
55
+ ## Configuration
56
+
57
+ Configuration lives in `~/.tokentrax/agent.env` (mode 0600). The agent
58
+ reads this file itself on every run — you do **not** need to `source` it.
59
+ That matters because a harness launched from a desktop launcher never
60
+ sources a shell profile, and the sync would then have no server URL.
61
+
62
+ ```bash
63
+ TOKENTRAX_SERVER_URL=http://your-dashboard:7680 # required
64
+ TOKENTRAX_SYNC_KEY=... # required, matches the server
65
+ TOKENTRAX_MACHINE_ID=my-laptop # optional, default: a hash
66
+ TOKENTRAX_TRACK_PROJECT=hash # hash | full | dirname | none
67
+ TOKENTRAX_ENV_FILE=/path/to/env # optional, override the path
68
+ ```
69
+
70
+ Real environment variables always win over the env file.
71
+
72
+ `TOKENTRAX_TRACK_PROJECT` controls how much of your project path is
73
+ reported. The default `hash` sends a SHA-256 prefix and nothing else; `full`
74
+ sends the absolute path.
75
+
76
+ ## How it works
77
+
78
+ Two sources of data are joined per session:
79
+
80
+ - **context-mode SessionDB** (`<adapter-home>/context-mode/sessions/*.db`) —
81
+ bytes avoided and returned, tool calls, events, reduction percentage.
82
+ - **Harness transcripts** — for pi, `~/.pi/agent/sessions/<project>/*.jsonl`,
83
+ which carry per-turn `message.usage` with token counts and costs.
84
+
85
+ The two are matched on project directory and session start time. A
86
+ transcript that context-mode never recorded is still reported, with its
87
+ token and cost data.
88
+
89
+ ## Sync mechanism
90
+
91
+ A **systemd user timer**, not a post-session hook.
92
+
93
+ Hooks are installed by `init` for harnesses that support them, but the
94
+ harness with the most data — pi — has no hook mechanism at all, so a hook
95
+ would silently never fire there. The timer works for every harness, present
96
+ and future, and keeps running when you are logged out (`Linger=yes`).
97
+
98
+ ```bash
99
+ systemctl --user list-timers tokentrax-agent.timer
100
+ journalctl --user -u tokentrax-agent.service -n 20
101
+ ```
102
+
103
+ ## Privacy
104
+
105
+ Nothing leaves the machine except session metadata and usage numbers, and
106
+ only to the one dashboard URL you configure. With the default
107
+ `TOKENTRAX_TRACK_PROJECT=hash` the dashboard never sees a project path.
108
+
109
+ ## License
110
+
111
+ MIT
package/bin/agent.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../src/cli');
package/install.sh ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Bootstrap the TokenTrax remote agent on this machine.
4
+ #
5
+ # curl -fsSL https://raw.githubusercontent.com/jeshuwah/TokenTrax/main/agent/install.sh \
6
+ # | bash -s -- --server http://your-dashboard:7680 --key <sync-key>
7
+ #
8
+ # Or from a clone of the repository:
9
+ #
10
+ # ./agent/install.sh --server http://your-dashboard:7680 --key <sync-key>
11
+ #
12
+ # Installs the agent into ~/.tokentrax/agent, then runs `tokentrax-agent
13
+ # setup` to write the configuration and enable the sync timer.
14
+ #
15
+ # Options:
16
+ # --server <url> TokenTrax server URL (required)
17
+ # --key <key> sync key, matches the server (required)
18
+ # --machine-id <id> name this machine in the dashboard
19
+ # --interval <min> sync interval in minutes (default 5)
20
+ # --track <level> hash | full | dirname | none (default hash)
21
+ # --dir <path> install location (default ~/.tokentrax/agent)
22
+ # --no-timer configure only, do not install the systemd timer
23
+ # --help this message
24
+
25
+ set -euo pipefail
26
+
27
+ REPO_URL="${TOKENTRAX_REPO_URL:-https://github.com/jeshuwah/TokenTrax.git}"
28
+ INSTALL_DIR="${TOKENTRAX_AGENT_DIR:-$HOME/.tokentrax/agent}"
29
+
30
+ SERVER=""
31
+ KEY=""
32
+ MACHINE_ID=""
33
+ INTERVAL=""
34
+ TRACK=""
35
+ NO_TIMER=""
36
+
37
+ die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }
38
+ info() { printf '\033[36m›\033[0m %s\n' "$*"; }
39
+ ok() { printf '\033[32m✓\033[0m %s\n' "$*"; }
40
+
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --server) SERVER="${2:-}"; shift 2 ;;
44
+ --key) KEY="${2:-}"; shift 2 ;;
45
+ --machine-id) MACHINE_ID="${2:-}"; shift 2 ;;
46
+ --interval) INTERVAL="${2:-}"; shift 2 ;;
47
+ --track) TRACK="${2:-}"; shift 2 ;;
48
+ --dir) INSTALL_DIR="${2:-}"; shift 2 ;;
49
+ --no-timer) NO_TIMER="1"; shift ;;
50
+ --server=*) SERVER="${1#*=}"; shift ;;
51
+ --key=*) KEY="${1#*=}"; shift ;;
52
+ --machine-id=*) MACHINE_ID="${1#*=}"; shift ;;
53
+ --interval=*) INTERVAL="${1#*=}"; shift ;;
54
+ --track=*) TRACK="${1#*=}"; shift ;;
55
+ --dir=*) INSTALL_DIR="${1#*=}"; shift ;;
56
+ -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
57
+ *) die "unknown option: $1 (try --help)" ;;
58
+ esac
59
+ done
60
+
61
+ [ -n "$SERVER" ] || die "--server is required"
62
+ [ -n "$KEY" ] || die "--key is required"
63
+
64
+ # ── Node ─────────────────────────────────────────────────────────────────────
65
+ command -v node >/dev/null 2>&1 || die "node is not installed. Install Node.js 20 or newer."
66
+ NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
67
+ [ "$NODE_MAJOR" -ge 20 ] || die "node $(node -v) is too old — need 20 or newer"
68
+ ok "node $(node -v)"
69
+
70
+ # ── Source ───────────────────────────────────────────────────────────────────
71
+ # If we're running from inside a clone, use it. Otherwise clone.
72
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
73
+ if [ -f "$SCRIPT_DIR/package.json" ] && [ -d "$SCRIPT_DIR/src" ]; then
74
+ SRC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
75
+ info "using the repository at $SRC_DIR"
76
+ else
77
+ mkdir -p "$(dirname "$INSTALL_DIR")"
78
+ if [ -d "$INSTALL_DIR/.git" ]; then
79
+ info "updating $INSTALL_DIR"
80
+ git -C "$INSTALL_DIR" pull --ff-only
81
+ else
82
+ info "cloning into $INSTALL_DIR"
83
+ git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
84
+ fi
85
+ SRC_DIR="$INSTALL_DIR"
86
+ fi
87
+
88
+ # ── Dependencies ─────────────────────────────────────────────────────────────
89
+ cd "$SRC_DIR/agent"
90
+ info "installing dependencies"
91
+ npm install --no-audit --no-fund
92
+
93
+ # npm 12+ blocks dependency install scripts by default, and better-sqlite3
94
+ # needs its install script to fetch or build the native binding. require()
95
+ # succeeds either way, so the failure only shows up when a database is
96
+ # opened.
97
+ if npm install-scripts ls >/dev/null 2>&1; then
98
+ info "approving better-sqlite3's install script (npm 12+ blocks these by default)"
99
+ npm install-scripts approve better-sqlite3 >/dev/null 2>&1 || true
100
+ npm rebuild better-sqlite3 >/dev/null 2>&1 || true
101
+ else
102
+ npm rebuild better-sqlite3 >/dev/null 2>&1 || true
103
+ fi
104
+
105
+ if ! node -e "const D=require('better-sqlite3'); const d=new D(':memory:'); d.prepare('SELECT 1').get(); d.close();" 2>/dev/null; then
106
+ die "better-sqlite3 could not open a database. Run:
107
+ npm install-scripts approve better-sqlite3
108
+ npm rebuild better-sqlite3"
109
+ fi
110
+ ok "better-sqlite3 loads"
111
+
112
+ # ── Configure and enable ─────────────────────────────────────────────────────
113
+ SETUP_ARGS=(setup --server "$SERVER" --key "$KEY")
114
+ [ -n "$MACHINE_ID" ] && SETUP_ARGS+=(--machine-id "$MACHINE_ID")
115
+ [ -n "$INTERVAL" ] && SETUP_ARGS+=(--interval "$INTERVAL")
116
+ [ -n "$TRACK" ] && SETUP_ARGS+=(--track-project "$TRACK")
117
+ [ -n "$NO_TIMER" ] && SETUP_ARGS+=(--no-timer)
118
+
119
+ node "$SRC_DIR/agent/bin/agent.js" "${SETUP_ARGS[@]}"
120
+
121
+ ok "TokenTrax remote agent installed in $SRC_DIR/agent"
122
+ echo
123
+ echo "Next:"
124
+ echo " tokentrax-agent status # confirm it can see the server"
125
+ echo " systemctl --user list-timers tokentrax-agent.timer"
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "tokentrax-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Remote agent for TokenTrax — reports AI-harness session usage from any machine to a central TokenTrax dashboard",
5
+ "type": "commonjs",
6
+ "main": "src/cli.js",
7
+ "bin": {
8
+ "tokentrax-agent": "bin/agent.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "install.sh",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test src/*.test.js"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/jeshuwah/TokenTrax.git",
26
+ "directory": "agent"
27
+ },
28
+ "keywords": [
29
+ "tokentrax",
30
+ "agent",
31
+ "telemetry",
32
+ "usage",
33
+ "tokens",
34
+ "cost",
35
+ "pi",
36
+ "codex",
37
+ "claude",
38
+ "cursor"
39
+ ],
40
+ "author": "Josh Greene",
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "better-sqlite3": "^12.0.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=20"
47
+ }
48
+ }
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // Canonical adapter registry — single source of truth.
9
+ //
10
+ // Resolution is deferred to call time (resolveAdapterHome) so tests and
11
+ // future env overrides (TOKENTRAX_HOME) don't lock in stale paths.
12
+ //
13
+ // Names are the canonical identifiers used as `record.adapter` downstream.
14
+ // - `antigravity` and `antigravity-cli` both live under ~/.gemini but
15
+ // identify as separate harnesses to the dashboard.
16
+ // - `vscode-copilot` and `jetbrains-copilot` keep the `-copilot` suffix
17
+ // so downstream consumers can distinguish editor-embedded adapters from
18
+ // CLI-only ones (`copilot-cli`).
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Static descriptor — only stores `name` and a `homeRelative` path.
23
+ * The absolute `home` and `sessionsDir` are derived per call via
24
+ * `resolveAdapterHome` / `buildAdapterEntry` so that os.homedir() changes
25
+ * (e.g. test monkey-patches) are honored.
26
+ *
27
+ * @typedef {Object} AdapterDescriptor
28
+ * @property {string} name Canonical adapter name
29
+ * @property {string} homeRel Path relative to homedir (may include subdirs)
30
+ */
31
+
32
+ const ADAPTER_DESCRIPTORS = Object.freeze([
33
+ { name: 'claude-code', homeRel: '.claude' },
34
+ { name: 'gemini-cli', homeRel: '.gemini' },
35
+ { name: 'antigravity', homeRel: '.gemini' },
36
+ { name: 'antigravity-cli', homeRel: '.gemini' },
37
+ { name: 'openclaw', homeRel: '.openclaw' },
38
+ { name: 'codex', homeRel: '.codex' },
39
+ { name: 'cursor', homeRel: '.cursor' },
40
+ { name: 'vscode-copilot', homeRel: '.vscode' },
41
+ { name: 'copilot-cli', homeRel: '.copilot' },
42
+ { name: 'kiro', homeRel: '.kiro' },
43
+ { name: 'pi', homeRel: '.pi' },
44
+ { name: 'omp', homeRel: '.omp' },
45
+ { name: 'qwen-code', homeRel: '.qwen' },
46
+ { name: 'kilo', homeRel: '.config/kilo' },
47
+ { name: 'opencode', homeRel: '.config/opencode' },
48
+ { name: 'zed', homeRel: '.config/zed' },
49
+ { name: 'jetbrains-copilot', homeRel: '.config/JetBrains' },
50
+ ]);
51
+
52
+ /**
53
+ * Resolve the absolute home directory for a known adapter name.
54
+ * @param {string} name
55
+ * @returns {string|undefined} absolute path, or undefined if name is unknown
56
+ */
57
+ function resolveAdapterHome(name) {
58
+ const desc = ADAPTER_DESCRIPTORS.find((d) => d.name === name);
59
+ if (!desc) return undefined;
60
+ return path.join(os.homedir(), desc.homeRel);
61
+ }
62
+
63
+ /**
64
+ * Build the absolute-path record for an adapter descriptor.
65
+ * @param {AdapterDescriptor} desc
66
+ * @returns {{name: string, home: string, sessionsDir: string}}
67
+ */
68
+ function buildAdapterEntry(desc) {
69
+ const home = path.join(os.homedir(), desc.homeRel);
70
+ return {
71
+ name: desc.name,
72
+ home,
73
+ sessionsDir: path.join(home, 'context-mode', 'sessions'),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Public, eagerly-computed ADAPTER_DIRS — kept for back-compat with the
79
+ * existing import in session-parser.js. Resolved once at module load, so
80
+ * if os.homedir() ever becomes dynamic, callers should use
81
+ * `resolveAdapterHome(name)` instead.
82
+ */
83
+ const ADAPTER_DIRS = Object.freeze(ADAPTER_DESCRIPTORS.map(buildAdapterEntry));
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Detection
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Check if an adapter is installed (has context-mode sessions or settings).
91
+ * Resolves home on each call so live filesystem changes are honored.
92
+ */
93
+ function detectAdapter(adapter) {
94
+ const home = resolveAdapterHome(adapter.name) || adapter.home;
95
+ const sessionsDir = path.join(home, 'context-mode', 'sessions');
96
+ const settingsJson = path.join(home, 'settings.json');
97
+ const settingsToml = path.join(home, 'settings.toml');
98
+ // Codex-specific: settings live at <home>/hooks.json, not settings.json/toml
99
+ const hooksJson = path.join(home, 'hooks.json');
100
+ const hasSessions = fs.existsSync(sessionsDir);
101
+ const hasSettings =
102
+ fs.existsSync(settingsJson) ||
103
+ fs.existsSync(settingsToml) ||
104
+ (adapter.name === 'codex' && fs.existsSync(hooksJson));
105
+ return { ...adapter, home, sessionsDir, hasSessions, hasSettings };
106
+ }
107
+
108
+ /**
109
+ * Detect all installed adapters.
110
+ * Returns adapters that have EITHER context-mode sessions OR settings.
111
+ */
112
+ function detectInstalled() {
113
+ return ADAPTER_DESCRIPTORS
114
+ .map((d) => detectAdapter(buildAdapterEntry(d)))
115
+ .filter((a) => a.hasSessions || a.hasSettings);
116
+ }
117
+
118
+ module.exports = {
119
+ ADAPTER_DIRS,
120
+ ADAPTER_DESCRIPTORS,
121
+ resolveAdapterHome,
122
+ detectInstalled,
123
+ };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const { test, describe } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const os = require('node:os');
8
+
9
+ // Load the module under test
10
+ const { ADAPTER_DIRS, detectInstalled, resolveAdapterHome } = require('./adapters');
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // ADAPTER_DIRS — single source of truth
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ describe('ADAPTER_DIRS (canonical registry)', () => {
17
+ test('contains the documented set of 17 adapters', () => {
18
+ assert.strictEqual(ADAPTER_DIRS.length, 17,
19
+ `Expected 17 adapters, got ${ADAPTER_DIRS.length}: ${ADAPTER_DIRS.map(a => a.name).join(', ')}`);
20
+ });
21
+
22
+ test('every entry has name, home, sessionsDir', () => {
23
+ for (const entry of ADAPTER_DIRS) {
24
+ assert.ok(typeof entry.name === 'string' && entry.name.length > 0,
25
+ `name required (entry: ${JSON.stringify(entry)})`);
26
+ assert.ok(typeof entry.home === 'string' && entry.home.length > 0,
27
+ `home required for ${entry.name}`);
28
+ assert.ok(typeof entry.sessionsDir === 'string' && entry.sessionsDir.length > 0,
29
+ `sessionsDir required for ${entry.name}`);
30
+ }
31
+ });
32
+
33
+ test('sessionsDir always ends with context-mode/sessions', () => {
34
+ for (const entry of ADAPTER_DIRS) {
35
+ assert.ok(entry.sessionsDir.endsWith('context-mode/sessions'),
36
+ `${entry.name}: sessionsDir should end with context-mode/sessions, got: ${entry.sessionsDir}`);
37
+ }
38
+ });
39
+
40
+ test('home is absolute and uses homedir()', () => {
41
+ for (const entry of ADAPTER_DIRS) {
42
+ assert.ok(path.isAbsolute(entry.home),
43
+ `${entry.name}: home should be absolute, got: ${entry.home}`);
44
+ assert.ok(entry.home.startsWith(os.homedir()),
45
+ `${entry.name}: home should be under homedir(), got: ${entry.home}`);
46
+ }
47
+ });
48
+
49
+ test('includes the canonical required adapters', () => {
50
+ const names = ADAPTER_DIRS.map((e) => e.name);
51
+ for (const required of ['claude-code', 'pi', 'codex', 'gemini-cli', 'cursor', 'zed']) {
52
+ assert.ok(names.includes(required), `should include ${required}`);
53
+ }
54
+ });
55
+
56
+ test('every adapter name is unique', () => {
57
+ const names = ADAPTER_DIRS.map((e) => e.name);
58
+ assert.strictEqual(new Set(names).size, names.length,
59
+ `duplicate adapter name in registry: ${names.join(', ')}`);
60
+ });
61
+ });
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // resolveAdapterHome — deferred resolution so env/test overrides work
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ describe('resolveAdapterHome', () => {
68
+ test('returns absolute path for a known adapter name', () => {
69
+ const home = resolveAdapterHome('pi');
70
+ assert.ok(path.isAbsolute(home));
71
+ assert.ok(home.startsWith(os.homedir()));
72
+ assert.ok(home.endsWith('.pi'));
73
+ });
74
+
75
+ test('returns undefined for an unknown adapter name', () => {
76
+ assert.strictEqual(resolveAdapterHome('nonexistent-adapter-xyz'), undefined);
77
+ });
78
+
79
+ test('is consistent across calls (stable for stable homedir)', () => {
80
+ const a = resolveAdapterHome('pi');
81
+ const b = resolveAdapterHome('pi');
82
+ assert.strictEqual(a, b, 'resolveAdapterHome should return same value for stable homedir');
83
+ });
84
+ });
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // detectInstalled
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ describe('detectInstalled', () => {
91
+ test('returns array of adapter descriptors', () => {
92
+ const installed = detectInstalled();
93
+ assert.ok(Array.isArray(installed));
94
+ for (const a of installed) {
95
+ assert.ok(typeof a.name === 'string');
96
+ assert.ok(typeof a.home === 'string');
97
+ assert.ok(typeof a.hasSessions === 'boolean');
98
+ assert.ok(typeof a.hasSettings === 'boolean');
99
+ }
100
+ });
101
+
102
+ test('every returned adapter has at least sessions or settings', () => {
103
+ const installed = detectInstalled();
104
+ for (const a of installed) {
105
+ assert.ok(a.hasSessions || a.hasSettings,
106
+ `detected adapter should have sessions or settings: ${a.name}`);
107
+ }
108
+ });
109
+
110
+ test('does not return adapters whose home does not exist', () => {
111
+ const installed = detectInstalled();
112
+ for (const a of installed) {
113
+ assert.ok(fs.existsSync(a.home),
114
+ `detected adapter ${a.name} home should exist: ${a.home}`);
115
+ }
116
+ });
117
+ });
118
+
119
+ // ─────────────────────────────────────────────────────────────────────────────
120
+ // Cross-module consistency: session-parser must use the same registry
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ describe('session-parser uses canonical ADAPTER_DIRS', () => {
124
+ test('session-parser ADAPTER_DIRS export matches adapters.js ADAPTER_DIRS', () => {
125
+ const { ADAPTER_DIRS: parserDirs } = require('./session-parser');
126
+ assert.strictEqual(parserDirs.length, ADAPTER_DIRS.length,
127
+ 'session-parser should re-export the canonical registry, not maintain its own');
128
+ for (let i = 0; i < parserDirs.length; i++) {
129
+ assert.strictEqual(parserDirs[i].name, ADAPTER_DIRS[i].name,
130
+ `name mismatch at index ${i}: ${parserDirs[i].name} vs ${ADAPTER_DIRS[i].name}`);
131
+ assert.strictEqual(parserDirs[i].home, ADAPTER_DIRS[i].home,
132
+ `home mismatch at index ${i}: ${parserDirs[i].home} vs ${ADAPTER_DIRS[i].home}`);
133
+ }
134
+ });
135
+ });