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.
- package/README.md +98 -10
- package/dist/chunk-2TUAFAIW.js +244 -0
- package/dist/{chunk-36DMIXH7.js → chunk-BJMASMKX.js} +13 -23
- package/dist/chunk-BLEGIR35.js +43 -0
- package/dist/chunk-C6GZ2L2W.js +38 -0
- package/dist/{chunk-VZVGTHGF.js → chunk-DO42NPNR.js} +11 -17
- package/dist/chunk-EW72ZNQL.js +39 -0
- package/dist/chunk-F7L25L2J.js +60 -0
- package/dist/{chunk-WHTH6WBP.js → chunk-LSUB6QMP.js} +3 -0
- package/dist/chunk-LVL25VLO.js +22 -0
- package/dist/chunk-SQL56SEB.js +14 -0
- package/dist/chunk-WBMX4CHB.js +378 -0
- package/dist/{chunk-ZGVUM4AG.js → chunk-YEC7IHIG.js} +276 -318
- package/dist/{chunk-E7TE4QZD.js → chunk-YH27B6SW.js} +9 -9
- package/dist/chunk-ZW4SW7LJ.js +225 -0
- package/dist/cli.js +70 -78
- package/dist/codex-stop-hook-JOTBCS5K.js +72 -0
- package/dist/doctor-TSHOMT5X.js +237 -0
- package/dist/doctor-codex-BMI5JOO6.js +130 -0
- package/dist/event-log-RSTM4PLL.js +18 -0
- package/dist/{hook-server-43QS7L7P.js → hook-server-QF5JVUHV.js} +28 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -2
- package/dist/{install-WV25CRU2.js → install-WBIUVBZW.js} +9 -7
- package/dist/{paired-config-OAR3O3XY.js → paired-config-JTFLHMZ2.js} +2 -1
- package/dist/resubscribe-SLZNA76S.js +59 -0
- package/dist/runtime-record-WO4IECM6.js +14 -0
- package/dist/runtimes-CO43XUUK.js +12 -0
- package/dist/{session-discovery-WSHLR4OV.js → session-discovery-FNMJGFPM.js} +2 -1
- package/dist/stop-hook-SEPWWETV.js +119 -0
- package/dist/tail-stream-BYKO4DW6.js +162 -0
- package/dist/{user-prompt-submit-hook-WSRIJVF4.js → user-prompt-submit-hook-ARPEO6FF.js} +5 -4
- package/dist/webhook-config-5TLLX7RA.js +10 -0
- package/dist/webhook-sink-7OYZBWXA.js +163 -0
- package/dist/wizard-7KHD5JT4.js +265 -0
- package/package.json +9 -7
- package/dist/event-log-ETWR6PPY.js +0 -112
- 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
|
|
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` (
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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:
|
|
172
|
+
command: `npx kojee-mcp tail "${eventLogPath}"`,
|
|
136
173
|
persistent: true,
|
|
137
174
|
description: "kojee Tandem events",
|
|
138
175
|
});
|
|
139
176
|
```
|
|
140
177
|
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
+
import psList from "ps-list";
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
|
-
function findClaudeAncestorPid(startPid = process.ppid) {
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|