kojee-mcp 0.4.0 → 0.5.2

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.
Files changed (38) hide show
  1. package/README.md +98 -10
  2. package/dist/chunk-2TUAFAIW.js +244 -0
  3. package/dist/{chunk-36DMIXH7.js → chunk-BJMASMKX.js} +13 -23
  4. package/dist/chunk-BLEGIR35.js +43 -0
  5. package/dist/chunk-C6GZ2L2W.js +38 -0
  6. package/dist/{chunk-VZVGTHGF.js → chunk-DO42NPNR.js} +11 -17
  7. package/dist/chunk-EW72ZNQL.js +39 -0
  8. package/dist/chunk-F7L25L2J.js +60 -0
  9. package/dist/{chunk-WHTH6WBP.js → chunk-LSUB6QMP.js} +3 -0
  10. package/dist/chunk-LVL25VLO.js +22 -0
  11. package/dist/chunk-SQL56SEB.js +14 -0
  12. package/dist/chunk-WBMX4CHB.js +378 -0
  13. package/dist/{chunk-ZGVUM4AG.js → chunk-YEC7IHIG.js} +276 -318
  14. package/dist/{chunk-E7TE4QZD.js → chunk-YH27B6SW.js} +9 -9
  15. package/dist/chunk-ZW4SW7LJ.js +225 -0
  16. package/dist/cli.js +70 -78
  17. package/dist/codex-stop-hook-JOTBCS5K.js +72 -0
  18. package/dist/doctor-TSHOMT5X.js +237 -0
  19. package/dist/doctor-codex-BMI5JOO6.js +130 -0
  20. package/dist/event-log-RSTM4PLL.js +18 -0
  21. package/dist/{hook-server-43QS7L7P.js → hook-server-QF5JVUHV.js} +28 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.js +5 -2
  24. package/dist/{install-WV25CRU2.js → install-WBIUVBZW.js} +9 -7
  25. package/dist/{paired-config-OAR3O3XY.js → paired-config-JTFLHMZ2.js} +2 -1
  26. package/dist/resubscribe-SLZNA76S.js +59 -0
  27. package/dist/runtime-record-WO4IECM6.js +14 -0
  28. package/dist/runtimes-CO43XUUK.js +12 -0
  29. package/dist/{session-discovery-WSHLR4OV.js → session-discovery-FNMJGFPM.js} +2 -1
  30. package/dist/stop-hook-SEPWWETV.js +119 -0
  31. package/dist/tail-stream-BYKO4DW6.js +162 -0
  32. package/dist/{user-prompt-submit-hook-WSRIJVF4.js → user-prompt-submit-hook-ARPEO6FF.js} +5 -4
  33. package/dist/webhook-config-5TLLX7RA.js +10 -0
  34. package/dist/webhook-sink-7OYZBWXA.js +163 -0
  35. package/dist/wizard-7KHD5JT4.js +265 -0
  36. package/package.json +9 -7
  37. package/dist/event-log-ETWR6PPY.js +0 -112
  38. package/dist/stop-hook-5XU3EQAE.js +0 -76
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # kojee-mcp
2
2
 
3
+ > **First time using Kojee Tandem in Claude Code?** See [docs/getting-started-tandem.md](docs/getting-started-tandem.md) for the 3-command setup + verification walkthrough.
4
+
3
5
  There are two ways to connect Kojee to an MCP-capable agent:
4
6
 
5
7
  1. **Mobile, web & desktop (recommended)** — paste the Kojee MCP URL into the app's "Add custom connector" dialog. The app handles OAuth login and consent. No local install. Works on Claude (web/desktop/iOS/Android) and ChatGPT (web/desktop/iOS/Android with Developer Mode enabled).
@@ -89,14 +91,47 @@ To remove kojee from Claude:
89
91
  npx kojee-mcp init --uninstall
90
92
  ```
91
93
 
94
+ ## Runtime-aware setup wizard (`init` selects the runtime)
95
+
96
+ `kojee-mcp init` is a runtime-aware wizard. The runtime selector is its core — one
97
+ proxy adapts to four harnesses. Bare `init` with no flag and no TTY still defaults
98
+ to `claude-code` (unchanged), so existing setups have zero regression.
99
+
100
+ ```bash
101
+ npx kojee-mcp init # interactive (prompts when stdin is a TTY)
102
+ npx kojee-mcp init --runtime claude-code # Claude Code (default): MCP entry + Stop/UserPromptSubmit hooks
103
+ npx kojee-mcp init --runtime codex --webhook-url https://your-receiver/kojee
104
+ # Codex: writes ~/.codex/config.toml + a codex-stop hook
105
+ npx kojee-mcp init --runtime hermes --webhook-url https://your-receiver/kojee
106
+ npx kojee-mcp init --runtime openclaw --webhook-url https://your-receiver/kojee
107
+ # daemon runtimes: print/record the webhook-sink env to export
108
+ ```
109
+
110
+ - **codex** — writes `[mcp_servers.kojee]` (with `env.KOJEE_RUNTIME="codex"` + the
111
+ webhook env) into `~/.codex/config.toml`, and a `codex-stop` Stop hook into
112
+ `~/.codex/hooks.json`. A `KOJEE_WEBHOOK_SECRET` is generated if you don't supply
113
+ one. Codex has no Claude-style channel injection, so its wake is the **webhook
114
+ sink + a stop-hook fast PEEK + a model-chosen bounded `tandem_listen` (cap 8s,
115
+ never a blanket long-poll)**. See `docs/RUNTIMES.md`.
116
+ - **hermes / openclaw** — run the proxy as a daemon and consume the webhook sink;
117
+ the wizard writes no config/hooks of ours, validates the webhook env, and prints
118
+ + records the env to export (secret redacted).
119
+
120
+ `kojee-mcp init --uninstall` is runtime-aware (uses the recorded runtime when
121
+ `--runtime` is omitted). `kojee-mcp doctor` is runtime-aware too.
122
+
123
+ > **Codex is build+PARK only:** there is no real Codex CLI on the build box, so the
124
+ > adapter + wizard are unit/integration-tested only. Live-Codex end-to-end is
125
+ > unverified — the wizard prints this note after a `--runtime codex` install.
126
+
92
127
  ## Claude Code Channels Support
93
128
 
94
129
  When this proxy detects it's running under Claude Code, it declares the `claude/channel` capability and pushes Tandem messages directly into the Claude session via `notifications/claude/channel`. Other MCP clients (Cursor, Codex, Openclaw, etc.) see no behavior change.
95
130
 
96
131
  **Runtime detection (3 tiers, first match wins):**
97
- 1. `KOJEE_RUNTIME=claude-code` env var (set by `kojee-mcp init` in the MCP entry's `env` block — survives Claude.app's env strip)
98
- 2. `CLAUDE_CODE_SESSION_ID` env var (set by the terminal `claude` CLI; **not** forwarded by Claude.app to MCP stdio servers)
99
- 3. Process-ancestry walk for a parent process whose command line matches `\bclaude\b` (handles fresh installs where neither env var is present)
132
+ 1. `KOJEE_RUNTIME` env var — `claude-code` CC, `codex` → Codex (set by `kojee-mcp init` in the MCP entry's `env` block — survives Claude.app's env strip). This is the **primary** path for Codex.
133
+ 2. `CLAUDE_CODE_SESSION_ID` env var (set by the terminal `claude` CLI; **not** forwarded by Claude.app to MCP stdio servers). CC only — Codex has no documented stable equivalent, so there is no Tier-2 Codex path.
134
+ 3. Process-ancestry walk for a parent process whose command line matches `\bclaude\b` ( CC) or `\bcodex\b` (→ Codex) — low-confidence fallback for fresh installs where no env var is present.
100
135
 
101
136
  CC Channels are in research preview — you must launch CC with `claude --dangerously-load-development-channels server:kojee` until kojee is on the official allowlist.
102
137
 
@@ -126,26 +161,35 @@ If both Channels (dangerous-flag launched) and hooks are active, the proxy dedup
126
161
 
127
162
  ## Waiting on Tandem Peers
128
163
 
129
- When kojee-mcp is running under Claude Code, the proxy writes one line per Tandem message to a per-session log file. The agent watches this log via Claude Code's built-in `Monitor` tool.
130
-
131
- **You don't need to figure out the path yourself.** The proxy bakes the resolved path into the MCP server's `instructions` string at startup, so the agent gets the exact command to spawn:
164
+ When kojee-mcp is running under Claude Code, the proxy writes one line per
165
+ Tandem message to a per-session log file under the OS's temp directory
166
+ (`os.tmpdir()` `/tmp` on Linux, `/var/folders/…` on macOS, `%TEMP%` on
167
+ Windows). The agent watches this log via Claude Code's built-in `Monitor`
168
+ tool — spawned once at the start of each session:
132
169
 
133
170
  ```ts
134
171
  Monitor({
135
- command: "tail -n +1 -F /tmp/kojee-events-<discoveryKey>.log",
172
+ command: `npx kojee-mcp tail "${eventLogPath}"`,
136
173
  persistent: true,
137
174
  description: "kojee Tandem events",
138
175
  });
139
176
  ```
140
177
 
141
- The `<discoveryKey>` is computed from `sha256(CLAUDE_PROJECT_DIR).slice(0,12) + '-' + <ccPid>` where `ccPid` is the parent Claude Code process. (This replaces the pre-v0.4 scheme that used `CLAUDE_CODE_SESSION_ID`, which Claude.app doesn't forward to MCP servers.) The matching `~/.kojee/sessions/cc-<discoveryKey>.json` discovery file lets the Stop / UserPromptSubmit hooks find this proxy via the same independent derivation.
178
+ `kojee-mcp tail` is a portable line-streamer shipped with this proxy
179
+ works the same on macOS, Linux, and Windows. The proxy interpolates the
180
+ resolved `eventLogPath` into the Channel-`instructions` string so the
181
+ agent receives a ready-to-run command.
182
+
183
+ The `<discoveryKey>` embedded in the log filename is computed from `sha256(CLAUDE_PROJECT_DIR).slice(0,12) + '-' + <ccPid>` where `ccPid` is the parent Claude Code process. (This replaces the pre-v0.4 scheme that used `CLAUDE_CODE_SESSION_ID`, which Claude.app doesn't forward to MCP servers.) The matching `~/.kojee/sessions/cc-<discoveryKey>.json` discovery file lets the Stop / UserPromptSubmit hooks find this proxy via the same independent derivation.
142
184
 
143
185
  Each appended event line arrives as a separate spontaneous wake notification. The agent stays free to chat with the user between events; CC delivers each event from idle as it arrives.
144
186
 
145
- **Stop hook is Monitor-aware** (v0.4+): when a Monitor is watching the log file, the Stop hook does an instant queue snapshot (~50ms) instead of a 30s long-poll, since events have already been delivered push-style. When no Monitor is running, the Stop hook long-polls for up to 30s AND emits a "spawn a Monitor" recommendation to the agent self-healing if the session-start spawn was skipped.
187
+ **Stop hook always long-polls** (up to 30s): the queue's `markMonitorDelivered` dedup ensures every event is delivered exactly once across the Channel / Monitor / Stop-hook paths, so the hook doesn't need to probe whether a Monitor is running. If the agent skipped the session-start Monitor spawn, the long-poll backstop still catches events; if Monitor is running, the dedup filters out anything already delivered push-style.
146
188
 
147
189
  Channel notifications (when available — see "Claude Code Channels Support" above) supplement this with mid-turn `<channel>` tag delivery, but Monitor is the default no-allowlist wake path that works for every user.
148
190
 
191
+ **Cross-platform support:** the proxy core, the `kojee-mcp tail` Monitor command, and the Channel wake path are portable across macOS, Linux, and Windows — path resolution uses `os.homedir()` / `os.tmpdir()` and process-ancestry detection uses the pure-JS `ps-list` package. Claude Code hook invocation on Windows is pending end-to-end verification; on macOS and Linux it is exercised by CI.
192
+
149
193
  For one-shot blocking waits (return as soon as a single reply arrives), call `tandem_listen(tandem_id, since=cursor, timeout_ms=N)` instead.
150
194
 
151
195
  ## Backend SSE Wire Compatibility
@@ -163,6 +207,50 @@ The proxy normalizes incoming SSE events from `/api/v2/tandems/stream` so that t
163
207
 
164
208
  The normalizer also accepts canonical (spec-aligned) payloads unchanged, so the proxy works against either shape during any future backend migration.
165
209
 
210
+ ## Webhook Sink (daemonized wake)
211
+
212
+ For runtimes that run the proxy as a daemon and inject Tandem events into a
213
+ session via a **local HTTP receiver** (e.g. Hermes), the proxy can POST every
214
+ Tandem event to a configured endpoint. It is a generic delivery sink — nothing
215
+ in it is Hermes-specific — and it is **OFF by default**.
216
+
217
+ | Env var | Meaning |
218
+ |---|---|
219
+ | `KOJEE_WEBHOOK_URL` | Receiver endpoint (http/https). **Unset ⇒ sink OFF** (zero behavior change). |
220
+ | `KOJEE_WEBHOOK_SECRET` | HMAC-SHA256 key for the signature header. URL set but secret unset ⇒ sink **DISABLED with an error** (the proxy NEVER sends unsigned webhooks). |
221
+ | `KOJEE_WEBHOOK_TIMEOUT_MS` | Per-attempt request timeout (default `5000`). |
222
+ | `KOJEE_WEBHOOK_MAX_RETRIES` | Retries on a retryable failure — network / 5xx / 408 / 429 (default `4`). |
223
+
224
+ **Receiver contract** (the single source of truth is `buildWebhookReceiverNote()`
225
+ in `src/tandem/recipe.ts`, and the exact body shape is the `WEBHOOK_BODY_SHAPE`
226
+ constant beside it):
227
+
228
+ - **Body** — each POST carries the **canonical normalized `TandemEvent`** as JSON
229
+ (the real field names a receiver sees, *not* the backend wire shape):
230
+
231
+ ```
232
+ { type, id, tandem_id, cursor, time,
233
+ from{ member_id, principal, agent_id?, session_id?, displayname },
234
+ kind, content{ body, format? }, mentions?, reply_to?, severity? }
235
+ ```
236
+
237
+ `from.session_id` and `severity` are present only when the wire carried them.
238
+ There is no `sender` object — the body is fully normalized and carries
239
+ `from.principal`.
240
+ - **Verify** the `X-Kojee-Signature` header: it is the hex-encoded SHA-256 HMAC
241
+ of the **raw request-body bytes**, keyed by `KOJEE_WEBHOOK_SECRET`. Recompute
242
+ over the received bytes (before any re-serialization) and timing-safe compare;
243
+ reject mismatches.
244
+ - **Dedupe** by `message_id` (the body's `id`, also in the `X-Kojee-Delivery`
245
+ header). Delivery is **at-least-once** — the proxy replays backlog from the
246
+ cursor on restart, so the same event may arrive more than once. There is **no
247
+ exactly-once** promise; the receiver's dedupe is what makes redelivery safe.
248
+
249
+ The sink is isolated and fire-and-forget: a slow, hanging, or failing webhook can
250
+ never delay or break the Monitor (event-log) or Channel wake paths. The status
251
+ log redacts the secret and strips any basic-auth credentials embedded in
252
+ `KOJEE_WEBHOOK_URL`.
253
+
166
254
  ## Development
167
255
 
168
256
  Run tests:
@@ -188,7 +276,7 @@ Some tools are governed by approval policies configured in the Kojee dashboard.
188
276
  3. The proxy translates this into a clear MCP response telling the agent that the action is awaiting human approval.
189
277
  4. Once approved (via dashboard, Slack, or other configured channel), the agent can retry the call and it will succeed.
190
278
 
191
- Step-up re-authentication and nonce rotation are handled transparently -- the agent never needs to deal with auth mechanics.
279
+ Nonce rotation is handled transparently -- the agent never needs to deal with auth mechanics. Step-up re-authentication is a **deprecated** feature (removed 2026-06-10): the proxy no longer polls for approval. Should a `step_up_required` response ever still occur, it surfaces immediately as a structured tool error carrying the reason, rather than a silent multi-minute wait.
192
280
 
193
281
  ## Troubleshooting
194
282
 
@@ -0,0 +1,244 @@
1
+ import {
2
+ sessionDiscoveryDir
3
+ } from "./chunk-DO42NPNR.js";
4
+ import {
5
+ secureFile
6
+ } from "./chunk-BLEGIR35.js";
7
+
8
+ // src/tandem/event-log.ts
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+ var DEFAULT_DIR = os.tmpdir();
13
+ var MAX_BODY_CHARS = 200;
14
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
15
+ var DEFAULT_STATUS_MAX_BYTES = 1 * 1024 * 1024;
16
+ var DEFAULT_MIN_AGE_MS = 6e4;
17
+ var STATUS_LINE_PREFIX = "kojee-status";
18
+ function statusLogPath(eventLogPath) {
19
+ const dir = path.dirname(eventLogPath);
20
+ const base = path.basename(eventLogPath);
21
+ const m = base.match(/^kojee-events-(.+)\.log$/);
22
+ return path.join(dir, m ? `kojee-status-${m[1]}.log` : `${base}.status`);
23
+ }
24
+ function startEventLog(opts) {
25
+ const dir = opts.dir ?? DEFAULT_DIR;
26
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
27
+ const statusMaxBytes = opts.statusMaxBytes ?? DEFAULT_STATUS_MAX_BYTES;
28
+ const filePath = path.join(dir, `kojee-events-${opts.key}.log`);
29
+ const statusPath = statusLogPath(filePath);
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ fs.writeFileSync(filePath, "", { mode: 384 });
32
+ secureFile(filePath);
33
+ fs.writeFileSync(statusPath, "", { mode: 384 });
34
+ secureFile(statusPath);
35
+ let writtenIds = /* @__PURE__ */ new Set();
36
+ let bytesWritten = 0;
37
+ let linesWritten = 0;
38
+ let statusBytesWritten = 0;
39
+ let writingMsg = Promise.resolve();
40
+ function enqueueMsg(fn) {
41
+ writingMsg = writingMsg.then(fn, fn);
42
+ return writingMsg;
43
+ }
44
+ let writingStatus = Promise.resolve();
45
+ function enqueueStatus(fn) {
46
+ writingStatus = writingStatus.then(fn, fn);
47
+ return writingStatus;
48
+ }
49
+ async function writeStatusRaw(fields) {
50
+ const note = formatStatusLine(fields);
51
+ if (statusBytesWritten >= statusMaxBytes) {
52
+ try {
53
+ await fs.promises.truncate(statusPath, 0);
54
+ statusBytesWritten = 0;
55
+ } catch (err) {
56
+ console.error("[event-log] status rotate-truncate failed:", err.message);
57
+ }
58
+ }
59
+ await fs.promises.appendFile(statusPath, note + "\n", { encoding: "utf8" });
60
+ statusBytesWritten += Buffer.byteLength(note + "\n");
61
+ }
62
+ async function writeMsgRaw(line) {
63
+ if (bytesWritten >= maxBytes) {
64
+ const dropped = linesWritten;
65
+ try {
66
+ await fs.promises.truncate(filePath, 0);
67
+ bytesWritten = 0;
68
+ linesWritten = 0;
69
+ writtenIds = /* @__PURE__ */ new Set();
70
+ void enqueueStatus(() => writeStatusRaw(`status=rotated dropped=${dropped}`)).catch(() => {
71
+ });
72
+ } catch (err) {
73
+ console.error("[event-log] rotate-truncate failed:", err.message);
74
+ }
75
+ }
76
+ await fs.promises.appendFile(filePath, line + "\n", { encoding: "utf8" });
77
+ bytesWritten += Buffer.byteLength(line + "\n");
78
+ linesWritten += 1;
79
+ }
80
+ return {
81
+ path: filePath,
82
+ statusPath,
83
+ async append(event) {
84
+ if (event.id && writtenIds.has(event.id)) return;
85
+ if (event.id) writtenIds.add(event.id);
86
+ const line = formatLine(event);
87
+ await enqueueMsg(async () => {
88
+ try {
89
+ await writeMsgRaw(line);
90
+ } catch (err) {
91
+ console.error("[event-log] append failed:", err.message);
92
+ if (event.id) writtenIds.delete(event.id);
93
+ }
94
+ });
95
+ },
96
+ async appendStatus(fields) {
97
+ await enqueueStatus(async () => {
98
+ try {
99
+ await writeStatusRaw(fields);
100
+ } catch (err) {
101
+ console.error("[event-log] appendStatus failed:", err.message);
102
+ }
103
+ });
104
+ },
105
+ cleanup() {
106
+ try {
107
+ fs.unlinkSync(filePath);
108
+ } catch {
109
+ }
110
+ try {
111
+ fs.unlinkSync(statusPath);
112
+ } catch {
113
+ }
114
+ }
115
+ };
116
+ }
117
+ function formatStatusLine(fields) {
118
+ return `[${(/* @__PURE__ */ new Date()).toISOString()}] ${STATUS_LINE_PREFIX} ${fields}`;
119
+ }
120
+ function formatLine(event) {
121
+ const body = (event.content?.body ?? "").replace(/[\r\n]+/g, " ").slice(0, MAX_BODY_CHARS + 1);
122
+ const truncated = body.length > MAX_BODY_CHARS;
123
+ const safeBody = truncated ? body.slice(0, MAX_BODY_CHARS) + "\u2026" : body;
124
+ return `[${event.time}] tandem=${event.tandem_id} from=${event.from.displayname} (${event.from.principal}) kind=${event.kind} cursor=${event.cursor} msg=${event.id}: ${safeBody}`;
125
+ }
126
+ function monitorHeartbeatPath(eventLogPath) {
127
+ const dir = path.dirname(eventLogPath);
128
+ const base = path.basename(eventLogPath);
129
+ const m = base.match(/^kojee-events-(.+)\.log$/);
130
+ return path.join(dir, m ? `kojee-monitor-${m[1]}.alive` : `${base}.alive`);
131
+ }
132
+ function nudgeSentinelPath(eventLogPath) {
133
+ const dir = path.dirname(eventLogPath);
134
+ const base = path.basename(eventLogPath);
135
+ const m = base.match(/^kojee-events-(.+)\.log$/);
136
+ return path.join(dir, m ? `kojee-nudge-${m[1]}.touch` : `${base}.nudge`);
137
+ }
138
+ function isProcessAlive(pid) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ return true;
142
+ } catch (err) {
143
+ if (err.code === "EPERM") return true;
144
+ return false;
145
+ }
146
+ }
147
+ function listActiveSessionIds() {
148
+ const dir = sessionDiscoveryDir();
149
+ const active = /* @__PURE__ */ new Set();
150
+ const livePaths = /* @__PURE__ */ new Set();
151
+ let entries;
152
+ try {
153
+ entries = fs.readdirSync(dir);
154
+ } catch {
155
+ return { active, livePaths };
156
+ }
157
+ for (const name of entries) {
158
+ if (!name.endsWith(".json")) continue;
159
+ const rawId = name.slice(0, -".json".length);
160
+ const sessionId = rawId.startsWith("cc-") ? rawId.slice("cc-".length) : rawId;
161
+ const filePath = path.join(dir, name);
162
+ try {
163
+ const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
164
+ if (typeof data.pid === "number" && isProcessAlive(data.pid)) {
165
+ active.add(sessionId);
166
+ if (data.eventLogPath) livePaths.add(path.resolve(data.eventLogPath));
167
+ } else {
168
+ try {
169
+ fs.unlinkSync(filePath);
170
+ } catch {
171
+ }
172
+ if (data.eventLogPath) {
173
+ try {
174
+ fs.unlinkSync(data.eventLogPath);
175
+ } catch {
176
+ }
177
+ try {
178
+ fs.unlinkSync(statusLogPath(data.eventLogPath));
179
+ } catch {
180
+ }
181
+ }
182
+ }
183
+ } catch {
184
+ }
185
+ }
186
+ return { active, livePaths };
187
+ }
188
+ function sweepStaleEventLogs(dir = DEFAULT_DIR, minAgeMs = DEFAULT_MIN_AGE_MS) {
189
+ try {
190
+ fs.unlinkSync(path.join(dir, "kojee-events-no-session.log"));
191
+ } catch {
192
+ }
193
+ const { active, livePaths } = listActiveSessionIds();
194
+ let entries;
195
+ try {
196
+ entries = fs.readdirSync(dir);
197
+ } catch {
198
+ return;
199
+ }
200
+ const now = Date.now();
201
+ for (const name of entries) {
202
+ if (!name.startsWith("kojee-events-") || !name.endsWith(".log")) continue;
203
+ const sessionId = name.slice("kojee-events-".length, -".log".length);
204
+ if (active.has(sessionId)) continue;
205
+ const filePath = path.join(dir, name);
206
+ if (livePaths.has(path.resolve(filePath))) continue;
207
+ try {
208
+ const stat = fs.statSync(filePath);
209
+ const ageMs = Math.max(0, now - stat.mtimeMs);
210
+ if (ageMs < minAgeMs) continue;
211
+ fs.unlinkSync(filePath);
212
+ try {
213
+ fs.unlinkSync(statusLogPath(filePath));
214
+ } catch {
215
+ }
216
+ } catch {
217
+ }
218
+ }
219
+ for (const name of entries) {
220
+ if (!name.startsWith("kojee-status-") || !name.endsWith(".log")) continue;
221
+ const statusFile = path.join(dir, name);
222
+ const key = name.slice("kojee-status-".length, -".log".length);
223
+ const messagesFile = path.join(dir, `kojee-events-${key}.log`);
224
+ if (active.has(key)) continue;
225
+ if (livePaths.has(path.resolve(messagesFile))) continue;
226
+ if (fs.existsSync(messagesFile)) continue;
227
+ try {
228
+ const stat = fs.statSync(statusFile);
229
+ const ageMs = Math.max(0, now - stat.mtimeMs);
230
+ if (ageMs < minAgeMs) continue;
231
+ fs.unlinkSync(statusFile);
232
+ } catch {
233
+ }
234
+ }
235
+ }
236
+
237
+ export {
238
+ STATUS_LINE_PREFIX,
239
+ statusLogPath,
240
+ startEventLog,
241
+ monitorHeartbeatPath,
242
+ nudgeSentinelPath,
243
+ sweepStaleEventLogs
244
+ };
@@ -1,36 +1,26 @@
1
1
  // src/runtime/ancestry.ts
2
- import childProcess from "child_process";
2
+ import psList from "ps-list";
3
3
  import { createHash } from "crypto";
4
- function findClaudeAncestorPid(startPid = process.ppid) {
5
- if (process.platform === "win32") return null;
4
+ async function findClaudeAncestorPid(startPid = process.ppid) {
5
+ let processes;
6
+ try {
7
+ processes = await psList();
8
+ } catch {
9
+ return null;
10
+ }
11
+ const byPid = /* @__PURE__ */ new Map();
12
+ for (const p of processes) byPid.set(p.pid, p);
6
13
  let pid = startPid;
7
14
  for (let depth = 0; depth < 20 && pid !== void 0 && pid > 1; depth++) {
8
- let row;
9
- try {
10
- row = readProcInfo(pid);
11
- } catch {
12
- return null;
13
- }
15
+ const row = byPid.get(pid);
14
16
  if (!row) return null;
15
- if (/\bclaude\b/.test(row.command)) return pid;
17
+ const haystack = `${row.name} ${row.cmd ?? ""}`;
18
+ if (/\bclaude\b/i.test(haystack)) return pid;
16
19
  if (row.ppid === void 0 || row.ppid === pid) return null;
17
20
  pid = row.ppid;
18
21
  }
19
22
  return null;
20
23
  }
21
- function readProcInfo(pid) {
22
- const out = childProcess.execFileSync("ps", ["-ww", "-p", String(pid), "-o", "ppid=,command="], {
23
- encoding: "utf8",
24
- stdio: ["ignore", "pipe", "ignore"]
25
- }).trim();
26
- if (!out) return null;
27
- const firstSpace = out.indexOf(" ");
28
- if (firstSpace < 0) return null;
29
- const ppid = Number.parseInt(out.slice(0, firstSpace).trim(), 10);
30
- if (!Number.isFinite(ppid)) return null;
31
- const command = out.slice(firstSpace + 1).trim();
32
- return { command, ppid };
33
- }
34
24
  function deriveDiscoveryKey(projectDir, ccPid) {
35
25
  const hasProjectDir = typeof projectDir === "string" && projectDir.length > 0;
36
26
  if (!hasProjectDir && ccPid === null) {
@@ -0,0 +1,43 @@
1
+ // src/secure-file.ts
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import { execFileSync } from "child_process";
5
+ function isWindows() {
6
+ return process.platform === "win32";
7
+ }
8
+ function secureFile(filePath) {
9
+ if (isWindows()) {
10
+ restrictAclToCurrentUser(filePath, "(F)");
11
+ } else {
12
+ try {
13
+ fs.chmodSync(filePath, 384);
14
+ } catch {
15
+ }
16
+ }
17
+ }
18
+ function secureDir(dirPath) {
19
+ if (isWindows()) {
20
+ restrictAclToCurrentUser(dirPath, "(OI)(CI)(F)");
21
+ } else {
22
+ try {
23
+ fs.chmodSync(dirPath, 448);
24
+ } catch {
25
+ }
26
+ }
27
+ }
28
+ function restrictAclToCurrentUser(target, permission) {
29
+ try {
30
+ const user = os.userInfo().username;
31
+ execFileSync(
32
+ "icacls",
33
+ [target, "/inheritance:r", "/grant:r", `${user}:${permission}`],
34
+ { stdio: "ignore" }
35
+ );
36
+ } catch {
37
+ }
38
+ }
39
+
40
+ export {
41
+ secureFile,
42
+ secureDir
43
+ };
@@ -0,0 +1,38 @@
1
+ // src/tandem/recipe.ts
2
+ var SEND_BODY_PARAM = "body";
3
+ function buildMonitorCommand(logPath) {
4
+ return `npx kojee-mcp tail "${logPath}"`;
5
+ }
6
+ function buildMonitorSpawn(logPath) {
7
+ return `Monitor(command=\`${buildMonitorCommand(logPath)}\`, persistent=true, description="kojee Tandem events")`;
8
+ }
9
+ function buildReplyRecipe(event) {
10
+ if (event) {
11
+ return `reply directly with tandem_send(tandem_id="${event.tandem_id}", ${SEND_BODY_PARAM}="...", reply_to="${event.message_id}"); fetch context only if needed via tandem_messages(tandem_id, since=cursor-1)`;
12
+ }
13
+ return `reply directly with tandem_send(tandem_id, ${SEND_BODY_PARAM}, reply_to=<msg>); fetch context only if needed via tandem_messages(tandem_id, since=cursor-1)`;
14
+ }
15
+ function buildCatchUpNote() {
16
+ return "If the event line `cursor=<n>` ever jumps (a gap) or the log resets (the proxy caps and truncates the messages log in place on overflow \u2014 a `status=rotated` line is recorded in the status sibling), you may have missed messages: catch up with tandem_messages(tandem_id, since=<last cursor you saw>).";
17
+ }
18
+ function buildMonitorNudge(logPath) {
19
+ return `[kojee] Tandem events are being logged but no Monitor is reading them \u2014 you may be missing wake notifications. Spawn the watcher once: ${buildMonitorSpawn(logPath)}. Then ${buildReplyRecipe()}.`;
20
+ }
21
+ var WEBHOOK_BODY_SHAPE = "{ type, id, tandem_id, cursor, time, from{ member_id, principal, agent_id?, session_id?, displayname }, kind, content{ body, format? }, mentions?, reply_to?, severity? }";
22
+ function buildWebhookReceiverNote() {
23
+ return "Webhook sink (optional, OFF unless KOJEE_WEBHOOK_URL + KOJEE_WEBHOOK_SECRET are set): the proxy POSTs every Tandem event as JSON to your endpoint. The body is the canonical normalized TandemEvent \u2014 " + WEBHOOK_BODY_SHAPE + " \u2014 where from.session_id and severity are present only when the wire carried them (the body is fully normalized: it carries from.principal, never the raw backend sender envelope). To build a receiver: (1) verify the X-Kojee-Signature header \u2014 it is the hex-encoded SHA-256 HMAC of the RAW request body bytes keyed by your KOJEE_WEBHOOK_SECRET; recompute over the received bytes and timing-safe compare, reject mismatches. (2) Dedupe by message_id \u2014 the body's `id`, also in the X-Kojee-Delivery header: delivery is AT-LEAST-ONCE (the proxy replays backlog from the cursor on restart), so the same event may arrive more than once \u2014 there is no exactly-once promise.";
24
+ }
25
+ var CODEX_LISTEN_CAP_MS = 8e3;
26
+ function buildCodexWakeReason(cursor) {
27
+ return `[kojee] new Tandem event(s) pending (cursor=${cursor}). If relevant to your task, drain them now: call tandem_messages(tandem_id, since=${cursor - 1}) to read, then ` + buildReplyRecipe() + `. If you expect an imminent reply and want to wait for exactly one, call tandem_listen(tandem_id, since=${cursor}, timeout_ms<=${CODEX_LISTEN_CAP_MS}) \u2014 a BOUNDED wait, cap 8s, NEVER an unconditional long-poll. If not relevant, ignore.`;
28
+ }
29
+
30
+ export {
31
+ buildMonitorSpawn,
32
+ buildReplyRecipe,
33
+ buildCatchUpNote,
34
+ buildMonitorNudge,
35
+ buildWebhookReceiverNote,
36
+ CODEX_LISTEN_CAP_MS,
37
+ buildCodexWakeReason
38
+ };
@@ -1,8 +1,14 @@
1
+ import {
2
+ secureDir,
3
+ secureFile
4
+ } from "./chunk-BLEGIR35.js";
5
+
1
6
  // src/tandem/session-discovery.ts
2
7
  import fs from "fs";
8
+ import os from "os";
3
9
  import path from "path";
4
10
  function sessionDiscoveryDir() {
5
- return path.join(process.env["HOME"] ?? "~", ".kojee", "sessions");
11
+ return path.join(os.homedir(), ".kojee", "sessions");
6
12
  }
7
13
  function sessionDiscoveryPath(sessionId) {
8
14
  return path.join(sessionDiscoveryDir(), `${sessionId}.json`);
@@ -13,16 +19,10 @@ function discoveryFileName(key) {
13
19
  function writeSessionDiscovery(sessionId, entry) {
14
20
  const dir = sessionDiscoveryDir();
15
21
  fs.mkdirSync(dir, { recursive: true, mode: 448 });
16
- try {
17
- fs.chmodSync(dir, 448);
18
- } catch {
19
- }
22
+ secureDir(dir);
20
23
  const filePath = sessionDiscoveryPath(sessionId);
21
24
  fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
22
- try {
23
- fs.chmodSync(filePath, 384);
24
- } catch {
25
- }
25
+ secureFile(filePath);
26
26
  }
27
27
  function readSessionDiscovery(sessionId) {
28
28
  try {
@@ -44,16 +44,10 @@ function discoveryPathForKey(key) {
44
44
  function writeDiscoveryByKey(key, entry) {
45
45
  const dir = sessionDiscoveryDir();
46
46
  fs.mkdirSync(dir, { recursive: true, mode: 448 });
47
- try {
48
- fs.chmodSync(dir, 448);
49
- } catch {
50
- }
47
+ secureDir(dir);
51
48
  const filePath = discoveryPathForKey(key);
52
49
  fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 384 });
53
- try {
54
- fs.chmodSync(filePath, 384);
55
- } catch {
56
- }
50
+ secureFile(filePath);
57
51
  }
58
52
  function cleanupDiscoveryByKey(key) {
59
53
  try {
@@ -0,0 +1,39 @@
1
+ import {
2
+ kojeeHomeDir
3
+ } from "./chunk-SQL56SEB.js";
4
+ import {
5
+ secureFile
6
+ } from "./chunk-BLEGIR35.js";
7
+
8
+ // src/wizard/runtime-record.ts
9
+ import fs from "fs";
10
+ import path from "path";
11
+ function runtimeRecordPath() {
12
+ return path.join(kojeeHomeDir(), ".kojee", "runtime");
13
+ }
14
+ function recordRuntime(runtime, recordPath = runtimeRecordPath()) {
15
+ fs.mkdirSync(path.dirname(recordPath), { recursive: true, mode: 448 });
16
+ fs.writeFileSync(recordPath, runtime + "\n", { mode: 384 });
17
+ secureFile(recordPath);
18
+ }
19
+ function readRecordedRuntime(recordPath = runtimeRecordPath()) {
20
+ try {
21
+ const raw = fs.readFileSync(recordPath, "utf8").trim();
22
+ return raw.length > 0 ? raw : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ function clearRuntimeRecord(recordPath = runtimeRecordPath()) {
28
+ try {
29
+ fs.unlinkSync(recordPath);
30
+ } catch {
31
+ }
32
+ }
33
+
34
+ export {
35
+ runtimeRecordPath,
36
+ recordRuntime,
37
+ readRecordedRuntime,
38
+ clearRuntimeRecord
39
+ };