kenaz 0.3.21 → 0.3.22

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.
@@ -5,6 +5,8 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
5
5
 
6
6
  你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
7
 
8
+ 用戶貼上房間 key 或要求加入/建立房間時,自己執行 `kenaz ygg join <key>` / `kenaz ygg create`(不要請用戶輸入),再轉述 status 區塊與「restart Claude Code」提示。
9
+
8
10
  ## Boot(一次 Bash)
9
11
  1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
10
12
  2. `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const os = require('os');
7
+ const fs = require('fs');
8
+ // decide() writes a peer opt-in marker (TASK_2465): keep it out of the real ~/.kenaz (spawned children inherit this).
9
+ process.env.KENAZ_PEER_OPTIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-optin-test-'));
10
+ const { decide } = require('../peer-session-fill');
11
+
12
+ const base = { session_id: 'sess-123', tool_input: { to: 'n', body: 'hi' } };
13
+
14
+ test('fills missing and wrong sessionId, keeps other args', () => {
15
+ for (const tn of ['mcp__kenaz-dashboard__kenaz_peer_send', 'mcp__plugin_kenaz_kenaz-dashboard__kenaz_peer_send']) {
16
+ for (const extra of [{}, { sessionId: 'wrong' }]) {
17
+ const out = decide({ ...base, tool_name: tn, tool_input: { ...base.tool_input, ...extra } });
18
+ assert.strictEqual(out.hookSpecificOutput.updatedInput.sessionId, 'sess-123');
19
+ assert.strictEqual(out.hookSpecificOutput.updatedInput.body, 'hi');
20
+ }
21
+ }
22
+ });
23
+
24
+ test('non-peer tools and missing session_id produce nothing', () => {
25
+ assert.strictEqual(decide({ ...base, tool_name: 'mcp__kenaz-dashboard__kenaz_peer_threads' }), null);
26
+ assert.strictEqual(decide({ ...base, tool_name: 'Bash' }), null);
27
+ assert.strictEqual(decide({ tool_name: 'mcp__x__kenaz_peer_send', tool_input: {} }), null);
28
+ });
29
+
30
+ test('main() prints JSON for a peer send and nothing on garbage stdin', () => {
31
+ const f = path.join(__dirname, '..', 'peer-session-fill.js');
32
+ const ok = spawnSync(process.execPath, [f], { input: JSON.stringify({ ...base, tool_name: 'mcp__a__kenaz_peer_send' }), encoding: 'utf8' });
33
+ assert.strictEqual(JSON.parse(ok.stdout).hookSpecificOutput.updatedInput.sessionId, 'sess-123');
34
+ const bad = spawnSync(process.execPath, [f], { input: '{nope', encoding: 'utf8' });
35
+ assert.strictEqual(bad.stdout, '');
36
+ assert.strictEqual(bad.status, 0);
37
+ });
@@ -181,4 +181,35 @@ function taskIdNeedles(id) {
181
181
  return /^\d+$/.test(s) ? [`TASK_${s}`] : [s];
182
182
  }
183
183
 
184
- module.exports = { isAnsuzConversation, lineIsAnsuzEntry, toolUseMentionsAny, taskIdNeedles, MARKERS };
184
+ // TASK_2465: peer collaborator opt-in. A session is a collaborator when it is Ansuz, OR it ran
185
+ // `ygg join`/`ygg create` / called kenaz_peer_send (peer-session-fill.js writes the marker), OR it was started
186
+ // by `kenaz launch` (KENAZ_PEER_OPTIN=1). One marker file per session id under ~/.kenaz/peer-optin, expiring,
187
+ // so mod / turn-context / Codex shim read it without parsing transcripts. Never opted in -> false (privacy).
188
+ const OPTIN_TTL_MS = 24 * 3600 * 1000;
189
+ const SID_OK = /^[A-Za-z0-9_-]{1,128}$/;
190
+ function optinDir() {
191
+ if (process.env.KENAZ_PEER_OPTIN_DIR) return process.env.KENAZ_PEER_OPTIN_DIR;
192
+ return path.join(require('os').homedir(), '.kenaz', 'peer-optin');
193
+ }
194
+ function markPeerOptIn(sessionId, now = Date.now()) {
195
+ const sid = String(sessionId || '');
196
+ if (!SID_OK.test(sid)) return false;
197
+ try {
198
+ fs.mkdirSync(optinDir(), { recursive: true });
199
+ fs.writeFileSync(path.join(optinDir(), sid + '.json'), JSON.stringify({ ts: now }));
200
+ return true;
201
+ } catch { return false; }
202
+ }
203
+ function isPeerOptedIn(sessionId, now = Date.now()) {
204
+ // kenaz launch's env opt-in is inherited by children: only the interactive entrypoint honours it, so a
205
+ // `claude -p` worker (sdk-cli) started inside a launched session never takes the human's peer mail.
206
+ if (process.env.KENAZ_PEER_OPTIN === '1' && process.env.CLAUDE_CODE_ENTRYPOINT === 'cli') return true;
207
+ const sid = String(sessionId || '');
208
+ if (!SID_OK.test(sid)) return false;
209
+ try {
210
+ const m = JSON.parse(fs.readFileSync(path.join(optinDir(), sid + '.json'), 'utf8'));
211
+ return Number.isFinite(m.ts) && now - m.ts >= 0 && now - m.ts < OPTIN_TTL_MS;
212
+ } catch { return false; }
213
+ }
214
+
215
+ module.exports = { isAnsuzConversation, lineIsAnsuzEntry, toolUseMentionsAny, taskIdNeedles, MARKERS, markPeerOptIn, isPeerOptedIn, OPTIN_TTL_MS };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // TASK_2465: peer collaboration opt-in marker (writer = peer-session-fill.js, reader = turn-context / mod / Codex shim).
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-optin-'));
9
+ process.env.KENAZ_PEER_OPTIN_DIR = dir;
10
+ delete process.env.KENAZ_PEER_OPTIN;
11
+ const { markPeerOptIn, isPeerOptedIn, OPTIN_TTL_MS } = require('./_ansuz-conversation');
12
+ const { decide } = require('./peer-session-fill');
13
+
14
+ test('no marker -> not opted in (privacy)', () => {
15
+ assert.equal(isPeerOptedIn('plain-session'), false);
16
+ assert.equal(isPeerOptedIn(''), false);
17
+ assert.equal(isPeerOptedIn(undefined), false);
18
+ });
19
+
20
+ test('kenaz_peer_send writes the marker for that session only', () => {
21
+ const out = decide({ tool_name: 'mcp__kenaz-dashboard__kenaz_peer_send', session_id: 'sess-a', tool_input: { to: 'x' } });
22
+ assert.equal(out.hookSpecificOutput.updatedInput.sessionId, 'sess-a'); // sessionId fill unchanged
23
+ assert.equal(isPeerOptedIn('sess-a'), true);
24
+ assert.equal(isPeerOptedIn('sess-b'), false);
25
+ });
26
+
27
+ test('ygg join / ygg create via Bash write the marker; other Bash commands do not', () => {
28
+ assert.equal(decide({ tool_name: 'Bash', session_id: 'sess-j', tool_input: { command: 'kenaz ygg join room-1' } }), null);
29
+ assert.equal(isPeerOptedIn('sess-j'), true);
30
+ decide({ tool_name: 'Bash', session_id: 'sess-c', tool_input: { command: 'node plugins/kenaz/scripts/ygg.js create demo' } });
31
+ assert.equal(isPeerOptedIn('sess-c'), true);
32
+ decide({ tool_name: 'Bash', session_id: 'sess-n', tool_input: { command: 'kenaz ygg status' } });
33
+ decide({ tool_name: 'Bash', session_id: 'sess-n', tool_input: { command: 'ls' } });
34
+ assert.equal(isPeerOptedIn('sess-n'), false);
35
+ });
36
+
37
+ test('only a real ygg invocation opts in; grep/echo/git mentions do not', () => {
38
+ const { yggInvocation } = require('./peer-session-fill');
39
+ for (const c of ['kenaz ygg join r', 'npx -y kenaz@1.2 ygg create x', 'node plugins/kenaz/scripts/ygg.js join r',
40
+ 'cd x && kenaz ygg join r', 'KENAZ_X=1 kenaz ygg create r | cat', 'ls; kenaz ygg join r']) assert.equal(yggInvocation(c), true, c);
41
+ for (const c of ['grep "ygg join" docs', 'echo kenaz ygg join', 'git log -S"ygg create"', 'cat ygg.js', 'kenaz ygg status',
42
+ 'grep -r ygg join . | wc -l', "echo 'x' # kenaz ygg join"]) assert.equal(yggInvocation(c), false, c);
43
+ });
44
+
45
+ test('marker expires', () => {
46
+ const t0 = Date.now();
47
+ markPeerOptIn('sess-e', t0);
48
+ assert.equal(isPeerOptedIn('sess-e', t0 + OPTIN_TTL_MS - 1000), true);
49
+ assert.equal(isPeerOptedIn('sess-e', t0 + OPTIN_TTL_MS + 1000), false);
50
+ });
51
+
52
+ test('unsafe session ids are refused (no path traversal)', () => {
53
+ assert.equal(markPeerOptIn('../evil'), false);
54
+ assert.equal(isPeerOptedIn('../evil'), false);
55
+ });
56
+
57
+ test('KENAZ_PEER_OPTIN=1 (kenaz launch) opts in without a marker; =0 does not', () => {
58
+ process.env.KENAZ_PEER_OPTIN = '1';
59
+ process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
60
+ assert.equal(isPeerOptedIn('never-marked'), false); // claude -p worker inherits the env
61
+ process.env.CLAUDE_CODE_ENTRYPOINT = 'cli';
62
+ assert.equal(isPeerOptedIn('never-marked'), true);
63
+ process.env.KENAZ_PEER_OPTIN = '0';
64
+ assert.equal(isPeerOptedIn('never-marked'), false);
65
+ delete process.env.KENAZ_PEER_OPTIN;
66
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
67
+ });
68
+
69
+ test('kenaz launch plan sets KENAZ_PEER_OPTIN=1 unless the caller set 0', () => {
70
+ const { plan } = require('../../.formation/bifrost/provider-launch.js');
71
+ const env = { DEEPSEEK_API_KEY: 'k' };
72
+ assert.equal(plan(['--provider', 'deepseek'], env, os.tmpdir(), null).env.KENAZ_PEER_OPTIN, '1');
73
+ assert.equal(plan(['--provider', 'deepseek'], { ...env, KENAZ_PEER_OPTIN: '0' }, os.tmpdir(), null).env.KENAZ_PEER_OPTIN, '0');
74
+ });
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ // PreToolUse(kenaz_peer_send, any MCP server prefix): fill the tool's `sessionId` with the hook payload's
3
+ // session_id, overriding whatever the model passed (TASK_2462). Reply affinity (TASK_2285) routes a reply back
4
+ // to the asking session only when the outbound row carries that id; the harness knows it, the model should not
5
+ // have to. Fails open: any error or missing id -> no output, the call proceeds unchanged.
6
+ const PEER_SEND = /^mcp__.*kenaz_peer_send$/;
7
+
8
+ // An actual invocation only: a `;`/`&&`/`||`/`|`/newline-separated segment (after optional VAR=x assignments) that starts with
9
+ // `kenaz ygg join|create`, `npx [-y] kenaz[@ver] ygg join|create` or `node <path>/ygg.js join|create`.
10
+ // Mentions inside grep/echo/git arguments or quotes do not count.
11
+ const YGG_CMD = /^(?:[A-Za-z_]\w*=\S*\s+)*(?:kenaz(?:\.cmd)?\s+ygg|npx\s+(?:-y\s+)?kenaz(?:@\S+)?\s+ygg|node\s+\S*ygg\.js)\s+(?:join|create)(?:\s|$)/;
12
+ function yggInvocation(cmd) {
13
+ return String(cmd || '').split(/\|\||&&|[;|&\n]/).some((seg) => YGG_CMD.test(seg.trim()));
14
+ }
15
+
16
+ function decide(input) {
17
+ // TASK_2465: opt-in marker (see _ansuz-conversation.js): `ygg join/create` via Bash, or any peer send.
18
+ try {
19
+ const sid0 = input && typeof input.session_id === 'string' ? input.session_id.trim() : '';
20
+ const cmd = input && input.tool_name === 'Bash' && input.tool_input ? String(input.tool_input.command || '') : '';
21
+ if (sid0 && (PEER_SEND.test(String(input.tool_name || '')) || yggInvocation(cmd))) require('./_ansuz-conversation').markPeerOptIn(sid0);
22
+ } catch (_) { /* marker is best-effort */ }
23
+ if (!input || !PEER_SEND.test(String(input.tool_name || ''))) return null;
24
+ const sid = typeof input.session_id === 'string' ? input.session_id.trim() : '';
25
+ if (!sid) return null;
26
+ const ti = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
27
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: Object.assign({}, ti, { sessionId: sid }) } };
28
+ }
29
+
30
+ function main() {
31
+ try {
32
+ const out = decide(JSON.parse(require('fs').readFileSync(0, 'utf8') || '{}'));
33
+ if (out) process.stdout.write(JSON.stringify(out));
34
+ } catch (_) { /* fail open */ }
35
+ }
36
+
37
+ if (require.main === module) main();
38
+ module.exports = { decide, main, yggInvocation };
@@ -214,14 +214,15 @@ const sessionLine = peerHookPayload.session_id
214
214
  fs.renameSync(tmpPath, STATE_PATH);
215
215
  } catch { /* best-effort state write */ }
216
216
 
217
- // 2. TASK_2122 — Peer Ansuz MVP: undelivered peer messages, Ansuz sessions
217
+ // 2. TASK_2122 — Peer Ansuz MVP: undelivered peer messages, collaborator sessions
218
218
  // only (DESIGN.md §3.4 — a plain Claude Code session sharing this project
219
- // dir must never see another user's collaborator traffic).
219
+ // dir must never see another user's collaborator traffic). TASK_2465: collaborator =
220
+ // Ansuz, or opted in (kenaz_peer_send / ygg join|create marker, or `kenaz launch`).
220
221
  let peerText = '';
221
222
  let waitingText = '';
222
223
  try {
223
- const { isAnsuzConversation } = require('./_ansuz-conversation');
224
- if (isAnsuzConversation(peerHookPayload.transcript_path, projectDir)) {
224
+ const { isAnsuzConversation, isPeerOptedIn } = require('./_ansuz-conversation');
225
+ if (isPeerOptedIn(peerHookPayload.session_id) || isAnsuzConversation(peerHookPayload.transcript_path, projectDir)) {
225
226
  // TASK_2226: a session whose kenaz-peer mod is alive gets its peer
226
227
  // messages from the mod (peek -> submit -> ack-by-id); pulling here too
227
228
  // would take-and-ack them out from under it. Fail-open: no session_id,
@@ -72,6 +72,15 @@
72
72
  ],
73
73
  "matcher": "mcp__kenaz-dashboard__.*|Read|Edit|Write"
74
74
  },
75
+ {
76
+ "matcher": "mcp__.*kenaz_peer_send|Bash",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "node -e \"const d=process.env.CLAUDE_PROJECT_DIR;process.chdir(d);require(d+'/.claude/hooks/peer-session-fill.js').main()\""
81
+ }
82
+ ]
83
+ },
75
84
  {
76
85
  "matcher": "Agent|Task",
77
86
  "hooks": [
@@ -65,6 +65,23 @@ async function peekPeerContext(p) {
65
65
  } catch (err) { console.error('[codex-hook-shim] peer pull err:', err.message); return { text: '', ids: [], root: '' }; }
66
66
  }
67
67
 
68
+ // TASK_2465: an active room = the server's yggdrasil config for the resolved root lists its effective room key in
69
+ // collaboration_projects (the activation). .kenaz/yggdrasil.yaml alone is committed repo config and proves nothing.
70
+ // Fail closed: server down, slow or odd answer -> no pull.
71
+ async function inActiveRoom(cwd, request = peerRequest) {
72
+ try {
73
+ const root = resolvePeerProjectRoot(process.env.KENAZ_CONSOLIDATE_ROOT || cwd);
74
+ if (!root) return false;
75
+ const cfg = await request('GET', '/api/yggdrasil/config?projectPath=' + encodeURIComponent(root));
76
+ const key = cfg && typeof cfg.effective_project_key === 'string' ? cfg.effective_project_key : '';
77
+ return !!key && Array.isArray(cfg.collaboration_projects) && cfg.collaboration_projects.includes(key);
78
+ } catch { return false; }
79
+ }
80
+ async function wantsPeerPull(env, argv, cwd, request) {
81
+ if (env.KENAZ_PEER_PULL === '0') return false;
82
+ return env.KENAZ_PEER_PULL === '1' || (argv || []).includes('--pull') || inActiveRoom(cwd, request);
83
+ }
84
+
68
85
  function ackPeer(root, ids) {
69
86
  return ids.length ? peerRequest('POST', '/api/peer/ack', { projectPath: root, ids }) : Promise.resolve(null);
70
87
  }
@@ -146,9 +163,9 @@ async function main() {
146
163
  const cb = rest.find((a) => typeof a === 'function'); if (cb) cb();
147
164
  return true;
148
165
  };
149
- // Pull when launch.js set KENAZ_PEER_PULL=1 or the registered hook carries --pull (independently opened
150
- // session); KENAZ_PEER_PULL=0 (headless relay) always wins.
151
- const wantPull = process.env.KENAZ_PEER_PULL !== '0' && (process.env.KENAZ_PEER_PULL === '1' || (process.argv || []).includes('--pull'));
166
+ // Pull when launch.js set KENAZ_PEER_PULL=1, the registered hook carries --pull (independently opened
167
+ // session), or (TASK_2465) the project is in an active room; KENAZ_PEER_PULL=0 (headless relay) always wins.
168
+ const wantPull = await wantsPeerPull(process.env, process.argv, p.cwd || process.cwd());
152
169
  const peerPeek = ev === 'UserPromptSubmit' && mode === 'full' && wantPull ? peekPeerContext(p) : null;
153
170
  try {
154
171
  const pr = handleHookEvent(payload);
@@ -187,4 +204,4 @@ async function main() {
187
204
  }
188
205
 
189
206
  if (require.main === module) main();
190
- module.exports = { peekPeerContext, ackPeer, writeUserPromptContext, frameForCodex };
207
+ module.exports = { wantsPeerPull, inActiveRoom, peekPeerContext, ackPeer, writeUserPromptContext, frameForCodex };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ // TASK_2465: Codex pulls peer mail by default when the server says the project's room is activated.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { wantsPeerPull } = require('./codex-hook-shim.js');
9
+
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-codex-room-'));
11
+ fs.mkdirSync(path.join(dir, '.kenaz'), { recursive: true });
12
+ fs.writeFileSync(path.join(dir, '.kenaz', 'project.yaml'), 'name: t\n');
13
+ // Committed repo config naming a room is NOT the activation.
14
+ fs.writeFileSync(path.join(dir, '.kenaz', 'yggdrasil.yaml'), 'yggdrasil_project: "room-a"\n');
15
+
16
+ const server = (cfg) => async (method, p) => { assert.equal(method, 'GET'); assert.ok(p.startsWith('/api/yggdrasil/config?projectPath=')); return cfg; };
17
+ const active = server({ effective_project_key: 'room-a', collaboration_projects: ['room-a'] });
18
+
19
+ test('activated room -> pulls without the flag', async () => {
20
+ assert.equal(await wantsPeerPull({}, ['node', 'shim'], dir, active), true);
21
+ });
22
+
23
+ test('room key present in repo config but not activated -> no pull', async () => {
24
+ assert.equal(await wantsPeerPull({}, [], dir, server({ effective_project_key: 'room-a', collaboration_projects: [] })), false);
25
+ assert.equal(await wantsPeerPull({}, [], dir, server({ effective_project_key: 'room-a', collaboration_projects: ['other'] })), false);
26
+ });
27
+
28
+ test('server down or odd answer fails closed', async () => {
29
+ assert.equal(await wantsPeerPull({}, [], dir, async () => null), false);
30
+ assert.equal(await wantsPeerPull({}, [], dir, async () => { throw new Error('x'); }), false);
31
+ assert.equal(await wantsPeerPull({}, [], dir, server({})), false);
32
+ });
33
+
34
+ test('KENAZ_PEER_PULL=0 wins over an activated room and --pull', async () => {
35
+ assert.equal(await wantsPeerPull({ KENAZ_PEER_PULL: '0' }, ['--pull'], dir, active), false);
36
+ });
37
+
38
+ test('KENAZ_PEER_PULL=1 and --pull still pull without a room', async () => {
39
+ const none = server({});
40
+ assert.equal(await wantsPeerPull({ KENAZ_PEER_PULL: '1' }, [], dir, none), true);
41
+ assert.equal(await wantsPeerPull({}, ['--pull'], dir, none), true);
42
+ });
@@ -45,7 +45,8 @@ function plan(argv, env = process.env, home = os.homedir(), live = null) {
45
45
  const key = p.key(env, home);
46
46
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
47
47
  const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
48
- KENAZ_PROVIDER: provider }; // tools (dispatch.js) tell the session runs on another vendor
48
+ KENAZ_PROVIDER: provider, // tools (dispatch.js) tell the session runs on another vendor
49
+ KENAZ_PEER_OPTIN: env.KENAZ_PEER_OPTIN || '1' }; // TASK_2465: launched sessions are peer collaborators; =0 opts out
49
50
  for (const k of p.unset) delete childEnv[k];
50
51
  return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
51
52
  }
@@ -1 +1 @@
1
- 0.3.21
1
+ 0.3.22
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // TASK_2367: CLI-first install. node install-kenaz-cli.js <install|uninstall|status> [flags]
4
+ // --dry-run print the plan, change nothing
5
+ // --home <dir> user home (default os.homedir(); tests use a temp dir)
6
+ // --core-dir <dir> kenaz-core root holding plugins/kenaz (default KENAZ_CORE_DIR or repo root)
7
+ // --server-bin <p> kenaz-server binary (default: first existing candidate)
8
+ // --no-service register the plugin only
9
+ // --no-plugin install the service only
10
+ // Plugin registration mirrors dashboard/src-tauri/src/features/project/peer_mod.rs (same env var,
11
+ // merge-never-replace, one-time backup, atomic write, CLI >= 2.1.287 gate, KENAZ_SKIP_CLAUDE_PROVISION opt-out).
12
+ // thrift: duplicate of the Rust logic until it is unified (see dev/plans/CLI_FIRST_INSTALL_FLOW.md).
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const http = require('http');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const ENV_KEY = 'CLAUDE_CODE_PLUGIN_DIRS';
20
+ const BACKUP = 'settings.json.bak-kenaz-peer'; // shared with the Rust path on purpose: one backup of the pristine file
21
+ const MIN_CLI = [2, 1, 287];
22
+ const SEP = process.platform === 'win32' ? ';' : ':';
23
+ const TASK_NAME = 'KenazServer';
24
+ const LABEL = 'ai.kenaz.server';
25
+
26
+ const norm = (e) => {
27
+ const s = String(e).trim().replace(/\\/g, '/').replace(/\/+$/, '');
28
+ return process.platform === 'win32' ? s.toLowerCase() : s;
29
+ };
30
+ const parseVersion = (s) => { const m = /(\d+)\.(\d+)\.(\d+)/.exec(s || ''); return m ? m.slice(1, 4).map(Number) : null; };
31
+ const cmpVersion = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i]; return 0; };
32
+
33
+ function loadSettings(file) {
34
+ let txt;
35
+ try { txt = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return {}; throw new Error('cannot read settings.json: ' + e.message); }
36
+ if (!txt.trim()) return {};
37
+ let v;
38
+ try { v = JSON.parse(txt); } catch (e) { throw new Error('settings.json is not valid JSON (' + e.message + '); left untouched'); }
39
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('settings.json is not a JSON object; left untouched');
40
+ return v;
41
+ }
42
+ function entriesOf(v) {
43
+ if (v.env === undefined) return [];
44
+ if (!v.env || typeof v.env !== 'object' || Array.isArray(v.env)) throw new Error('settings.json `env` is not an object; left untouched');
45
+ const s = v.env[ENV_KEY];
46
+ if (s === undefined) return [];
47
+ if (typeof s !== 'string') throw new Error('env.' + ENV_KEY + ' is not a string; left untouched');
48
+ return s.split(SEP).map((x) => x.trim()).filter(Boolean);
49
+ }
50
+ function saveSettings(file, v) {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const bak = path.join(path.dirname(file), BACKUP);
53
+ if (fs.existsSync(file) && !fs.existsSync(bak)) fs.copyFileSync(file, bak);
54
+ const tmp = file + '.kenaz-tmp';
55
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
56
+ fs.renameSync(tmp, file);
57
+ }
58
+ function setEntries(v, list) {
59
+ v.env = v.env || {};
60
+ if (list.length) v.env[ENV_KEY] = list.join(SEP); else delete v.env[ENV_KEY];
61
+ }
62
+ const isWorktreePath = (d) => /(^|\/)\.kenaz-worktrees(\/|$)/.test(norm(d));
63
+ // TASK_2384: a stale copy is another checkout of the same plugin (.../plugins/<name>): release core, worktree.
64
+ const staleCopy = (e, dir) => norm(e) !== norm(dir) && norm(e).endsWith('/plugins/' + norm(dir).split('/').pop());
65
+ /** One path per Kenaz plugin: older copies are replaced in place, never appended. A worktree checkout is
66
+ * never registered in user settings. @returns 'added'|'already'|'replaced' */
67
+ function registerPlugin(file, dir, dry) {
68
+ if (isWorktreePath(dir)) throw new Error('refusing to register a .kenaz-worktrees path in user settings: ' + dir);
69
+ const v = loadSettings(file), list = entriesOf(v);
70
+ const mine = (e) => norm(e) === norm(dir) || staleCopy(e, dir);
71
+ const has = list.some((e) => norm(e) === norm(dir));
72
+ if (has && list.filter(mine).length === 1) return 'already';
73
+ const first = list.findIndex(mine);
74
+ if (first < 0) { if (!dry) { list.push(dir); setEntries(v, list); saveSettings(file, v); } return 'added'; }
75
+ const next = list.map((e, i) => (i === first ? dir : e)).filter((e, i) => i === first || !mine(e));
76
+ if (!dry) { setEntries(v, next); saveSettings(file, v); }
77
+ return 'replaced';
78
+ }
79
+ /** @returns 'removed'|'absent' */
80
+ function unregisterPlugin(file, dir, dry) {
81
+ if (!fs.existsSync(file)) return 'absent';
82
+ const v = loadSettings(file), list = entriesOf(v);
83
+ const kept = list.filter((e) => norm(e) !== norm(dir));
84
+ if (kept.length === list.length) return 'absent';
85
+ if (!dry) { setEntries(v, kept); saveSettings(file, v); }
86
+ return 'removed';
87
+ }
88
+
89
+ // Status line with the project's task counts (plugins/kenaz/scripts/statusline.js). Set only when the user has no
90
+ // statusLine; a user's own is never replaced. Ours is recognised by the script path, so a moved core is updated.
91
+ const STATUSLINE_TAIL = 'plugins/kenaz/scripts/statusline.js';
92
+ const isOurStatusline = (s) => !!(s && typeof s.command === 'string' && norm(s.command).includes(STATUSLINE_TAIL));
93
+ /** @returns 'added'|'already'|'replaced'|'kept-user' */
94
+ function registerStatusline(file, core, dry) {
95
+ const v = loadSettings(file);
96
+ const command = 'node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"';
97
+ if (v.statusLine && !isOurStatusline(v.statusLine)) return 'kept-user';
98
+ if (v.statusLine && norm(v.statusLine.command) === norm(command)) return 'already';
99
+ const r = v.statusLine ? 'replaced' : 'added';
100
+ if (!dry) { v.statusLine = { type: 'command', command }; saveSettings(file, v); }
101
+ return r;
102
+ }
103
+ /** @returns 'removed'|'absent' */
104
+ function unregisterStatusline(file, dry) {
105
+ if (!fs.existsSync(file)) return 'absent';
106
+ const v = loadSettings(file);
107
+ if (!isOurStatusline(v.statusLine)) return 'absent';
108
+ if (!dry) { delete v.statusLine; saveSettings(file, v); }
109
+ return 'removed';
110
+ }
111
+
112
+ // TASK_2384: Dashboard-era user-level hooks (wrapped by .claude/hooks/_user-scope.js, direct .claude/hooks/*.js, or the
113
+ // agent-flow curl/node posts) duplicate what plugins/kenaz/hooks/hooks.json now runs. Single owner = the plugin, so
114
+ // once it is registered these are removed (first write backs up the pristine file to settings.json.bak-kenaz-peer).
115
+ const DASH_HOOK = /_user-scope\.js|[\\/]\.claude[\\/]hooks[\\/][\w.-]+\.js|\/api\/rust\/agent-flow\/hook/;
116
+ /** @returns number of hook commands retired */
117
+ function retireDashboardHooks(file, dry) {
118
+ if (!fs.existsSync(file)) return 0;
119
+ const v = loadSettings(file);
120
+ if (!v.hooks || typeof v.hooks !== 'object' || Array.isArray(v.hooks)) return 0;
121
+ let n = 0;
122
+ for (const ev of Object.keys(v.hooks)) {
123
+ if (!Array.isArray(v.hooks[ev])) continue;
124
+ const groups = [];
125
+ for (const g of v.hooks[ev]) {
126
+ if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
127
+ const kept = g.hooks.filter((h) => !(h && typeof h.command === 'string' && DASH_HOOK.test(h.command)));
128
+ n += g.hooks.length - kept.length;
129
+ if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
130
+ }
131
+ if (groups.length) v.hooks[ev] = groups; else delete v.hooks[ev];
132
+ }
133
+ if (n && !dry) { if (!Object.keys(v.hooks).length) delete v.hooks; saveSettings(file, v); }
134
+ return n;
135
+ }
136
+
137
+ function claudeVersion() {
138
+ const r = process.platform === 'win32' // one string with shell (.cmd shim): args + shell:true is deprecated (DEP0190)
139
+ ? spawnSync('claude --version', { encoding: 'utf8', shell: true, timeout: 15000 })
140
+ : spawnSync('claude', ['--version'], { encoding: 'utf8', timeout: 15000 });
141
+ return r.status === 0 ? parseVersion(r.stdout) : null;
142
+ }
143
+
144
+ // ---- service definitions (pure; the OS calls are in service* below) ----
145
+ function launcherPaths(home) {
146
+ const d = path.join(home, '.kenaz');
147
+ return { dir: d, vbs: path.join(d, 'kenaz-server.vbs'), log: path.join(d, 'kenaz-server.log') };
148
+ }
149
+ const q = (s) => '"' + s + '"';
150
+ function winVbs(bin, core) { // hidden window, env for task tools; server binds loopback by default
151
+ return 'Set sh = WScript.CreateObject("WScript.Shell")\r\nsh.Environment("Process")("KENAZ_SOURCE_DIR") = "' + core + '"\r\n' +
152
+ 'sh.Run """' + bin + '""", 0, False\r\n';
153
+ }
154
+ // launchd and systemd start the server with a bare PATH (/usr/bin:/bin...), so `node` from Homebrew,
155
+ // nvm or /usr/local was not found and every MCP tool that runs a node script failed with
156
+ // "No such file or directory" (field report, macOS 0.3.1). Put the node running this install first.
157
+ function servicePath(nodeExe = process.execPath) {
158
+ return [path.dirname(nodeExe), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']
159
+ .filter((d, i, a) => a.indexOf(d) === i).join(':');
160
+ }
161
+ function launchdPlist(bin, core, log, nodeExe) {
162
+ return '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n' +
163
+ ' <key>Label</key><string>' + LABEL + '</string>\n <key>ProgramArguments</key><array><string>' + bin + '</string></array>\n' +
164
+ ' <key>EnvironmentVariables</key><dict><key>KENAZ_SOURCE_DIR</key><string>' + core + '</string><key>PATH</key><string>' + servicePath(nodeExe) + '</string></dict>\n' +
165
+ ' <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><true/>\n <key>StandardOutPath</key><string>' + log + '</string>\n <key>StandardErrorPath</key><string>' + log + '</string>\n</dict></plist>\n';
166
+ }
167
+ function systemdUnit(bin, core, nodeExe) {
168
+ return '[Unit]\nDescription=Kenaz server (headless backend)\nAfter=network.target\n\n[Service]\nExecStart=' + bin + '\nEnvironment=KENAZ_SOURCE_DIR=' + core + '\nEnvironment=PATH=' + servicePath(nodeExe) + '\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n';
169
+ }
170
+
171
+ // TASK_2413: no usable systemd user session (containers, WSL1, minimal servers) -> `systemctl --user enable --now`
172
+ // silently does nothing. Fall back to an @reboot crontab entry ('cron') or, without crontab, a manual start ('manual').
173
+ const CRON_MARK = '# kenaz-server (managed by kenaz install)';
174
+ const shq = (v) => "'" + String(v).replace(/'/g, "'\\''") + "'";
175
+ function cronStartCmd(bin, core, log, nodeExe) {
176
+ return 'KENAZ_SOURCE_DIR=' + shq(core) + ' PATH=' + shq(servicePath(nodeExe)) + ' ' + shq(bin) + ' >> ' + shq(log) + ' 2>&1';
177
+ }
178
+ function cronLine(bin, core, log, nodeExe) { // % is special in crontab
179
+ return '@reboot ' + cronStartCmd(bin, core, log, nodeExe).replace(/%/g, '\\%') + ' ' + CRON_MARK;
180
+ }
181
+ // stop exactly this binary (never by process name: another install's kenaz-server must survive). An upgraded
182
+ // process still runs the unlinked inode, which /proc reports as "<path> (deleted)".
183
+ function stopCmd(plan) {
184
+ const b = shq(plan.bin);
185
+ return 'for p in $(pgrep -x kenaz-server); do e=$(readlink /proc/$p/exe); ' +
186
+ '[ "$e" = ' + b + ' ] || [ "$e" = ' + shq(plan.bin + ' (deleted)') + ' ] && kill $p; done; true';
187
+ }
188
+ // start now, detached from the installer (new session when setsid exists), output to the log
189
+ function detachedStartCmd(plan) {
190
+ return 'if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi; ' +
191
+ 'KENAZ_SOURCE_DIR=' + shq(plan.core) + ' PATH=' + shq(servicePath()) + ' $S nohup ' + shq(plan.bin) + ' >> ' + shq(plan.log) + ' 2>&1 < /dev/null &';
192
+ }
193
+
194
+ function servicePlan(home, bin, core, platform = process.platform, caps = { systemd: true }) {
195
+ const L = launcherPaths(home);
196
+ if (platform === 'win32') {
197
+ const roaming = home === os.homedir() && process.env.APPDATA ? process.env.APPDATA : path.join(home, 'AppData', 'Roaming');
198
+ const startup = path.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'KenazServer.vbs');
199
+ return { kind: 'windows', files: [[L.vbs, winVbs(bin, core)]], fallbackFile: startup };
200
+ }
201
+ if (platform === 'darwin') {
202
+ const p = path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist');
203
+ return { kind: 'launchd', files: [[p, launchdPlist(bin, core, L.log)]], unit: p };
204
+ }
205
+ if (caps.systemd === false) {
206
+ return { kind: caps.crontab ? 'cron' : 'manual', files: [], bin, core, log: L.log, cronLine: cronLine(bin, core, L.log) };
207
+ }
208
+ const p = path.join(home, '.config', 'systemd', 'user', 'kenaz-server.service');
209
+ return { kind: 'systemd', files: [[p, systemdUnit(bin, core)]], unit: p };
210
+ }
211
+ const sh = (cmd, args, opts) => spawnSync(cmd, args, { encoding: 'utf8', ...(opts || {}) });
212
+
213
+ // crontab -l: null = no crontab binary; '' = binary present, no crontab yet. All through the injected `run`.
214
+ function readCron(run) {
215
+ const r = run('crontab', ['-l']);
216
+ if (!r || r.error || r.status === 127) return null;
217
+ return r.status === 0 ? String(r.stdout || '') : '';
218
+ }
219
+ function probeLinux(run) {
220
+ const r = run('systemctl', ['--user', 'show-environment']);
221
+ const systemd = !!r && !r.error && r.status === 0;
222
+ return { systemd, crontab: systemd ? undefined : readCron(run) !== null };
223
+ }
224
+ function writeCron(run, current, line) { // line null = remove our entry; keeps every other line; idempotent
225
+ const keep = current.split('\n').filter((l) => l.trim() && !l.includes(CRON_MARK));
226
+ const next = line ? keep.concat(line) : keep;
227
+ const text = next.length ? next.join('\n') + '\n' : '';
228
+ if (text === current) return false;
229
+ if (text) run('crontab', ['-'], { input: text }); else run('crontab', ['-r']);
230
+ return true;
231
+ }
232
+
233
+ // TASK_2418: loading/enabling a unit that is already loaded/active is a no-op, so an upgrade must restart it
234
+ // explicitly, after the new binary is in place. launchd KeepAlive respawns the OLD file in the window between
235
+ // stopOwnServer() and the copy (the process then keeps the unlinked inode); systemd `enable --now` does not
236
+ // restart an active unit. restartHint is also the command printed when the version check fails.
237
+ const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : 501);
238
+ function restartHint(plan, uid = defaultUid()) {
239
+ if (plan.kind === 'launchd') return 'launchctl kickstart -k gui/' + uid + '/' + LABEL;
240
+ if (plan.kind === 'systemd') return 'systemctl --user restart kenaz-server';
241
+ if (plan.kind === 'cron' || plan.kind === 'manual') return stopCmd(plan) + '; ' + detachedStartCmd(plan);
242
+ // the launcher works for both the scheduled task and the Startup-folder fallback (no admin rights needed)
243
+ return 'end kenaz-server.exe in Task Manager, then run: wscript.exe ' + q(plan.files && plan.files[0] ? plan.files[0][0] : 'kenaz-server.vbs');
244
+ }
245
+ function serviceRestart(plan, dry, log, run = sh, uid = defaultUid()) {
246
+ if (plan.kind === 'cron' || plan.kind === 'manual') { // no supervisor: start it (stopOwnServer freed the old one)
247
+ log('start now (detached, log ' + plan.log + ')');
248
+ if (!dry) { fs.mkdirSync(path.dirname(plan.log), { recursive: true }); run('sh', ['-c', detachedStartCmd(plan)]); }
249
+ return;
250
+ }
251
+ if (plan.kind === 'windows') return; // /Create + /Run above start the just-copied exe (stopOwnServer freed it)
252
+ log(restartHint(plan, uid));
253
+ if (dry) return;
254
+ if (plan.kind === 'launchd') {
255
+ const r = run('launchctl', ['kickstart', '-k', 'gui/' + uid + '/' + LABEL]);
256
+ if (r && r.status !== 0) { // old macOS / no GUI session domain: unload + load restarts it the long way
257
+ log('kickstart failed (exit ' + r.status + '), falling back to launchctl unload + load');
258
+ run('launchctl', ['unload', plan.unit]); run('launchctl', ['load', '-w', plan.unit]);
259
+ }
260
+ } else run('systemctl', ['--user', 'restart', 'kenaz-server']);
261
+ }
262
+
263
+ /** One GET /api/health; resolves { ok, version }. */
264
+ function healthOnce(port, getFn = http.get) {
265
+ return new Promise((resolve) => {
266
+ const req = getFn({ host: '127.0.0.1', port, path: '/api/health', timeout: 2000 }, (res) => {
267
+ let b = ''; res.on('data', (d) => { b += d; });
268
+ res.on('end', () => { let v = null; try { v = JSON.parse(b).version || null; } catch (_) { /* body optional */ } resolve({ ok: res.statusCode < 500, version: v }); });
269
+ });
270
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, version: null }); });
271
+ req.on('error', () => resolve({ ok: false, version: null }));
272
+ });
273
+ }
274
+ /**
275
+ * TASK_2418: after the service (re)start, ask the running server for its version and compare with the one just
276
+ * installed. Retries (the server needs a moment to bind). Returns 'ok' | 'mismatch' | 'unreachable'; the last two
277
+ * print a warning with the exact restart command and do not fail the install.
278
+ */
279
+ async function verifyRuntime(plan, expected, log, opts = {}) {
280
+ const port = opts.port || process.env.KENAZ_HTTP_PORT || '18080';
281
+ const tries = opts.tries || 6, delay = opts.delayMs === undefined ? 1000 : opts.delayMs;
282
+ const probe = opts.health || ((p) => healthOnce(p));
283
+ let last = { ok: false, version: null };
284
+ for (let i = 0; i < tries; i++) {
285
+ last = await probe(port);
286
+ if (last.ok && last.version === expected) { log('server: running version ' + expected + ' on :' + port); return 'ok'; }
287
+ if (i < tries - 1 && delay) await new Promise((r) => setTimeout(r, delay));
288
+ }
289
+ const hint = restartHint(plan, opts.uid);
290
+ if (last.ok && last.version) {
291
+ log('WARNING: the server on :' + port + ' still reports version ' + last.version + ' but ' + expected + ' was just installed. Restart it: ' + hint);
292
+ return 'mismatch';
293
+ }
294
+ log('WARNING: could not confirm the new server (' + expected + ') on 127.0.0.1:' + port + (last.ok ? ' (no version in /api/health)' : ' (no answer)') + '. If it is not running: ' + hint);
295
+ return 'unreachable';
296
+ }
297
+
298
+ function serviceInstall(plan, dry, log, run = sh, uid) {
299
+ const sh = run; // injectable for tests; shadows the module-level spawnSync wrapper inside this function
300
+ for (const [f, body] of plan.files) { log('write ' + f); if (!dry) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body); } }
301
+ if (plan.kind === 'windows') {
302
+ const tr = 'wscript.exe ' + q(plan.files[0][0]);
303
+ log('schtasks /Create /TN ' + TASK_NAME + ' /SC ONLOGON /TR ' + tr + ' /F (fallback: ' + plan.fallbackFile + ')');
304
+ if (dry) return;
305
+ const r = sh('schtasks', ['/Create', '/TN', TASK_NAME, '/SC', 'ONLOGON', '/TR', tr, '/F']);
306
+ if (r.status === 0) sh('schtasks', ['/Run', '/TN', TASK_NAME]);
307
+ else { // ONLOGON tasks can need elevation: a Startup-folder launcher is the per-user equivalent
308
+ fs.mkdirSync(path.dirname(plan.fallbackFile), { recursive: true });
309
+ fs.copyFileSync(plan.files[0][0], plan.fallbackFile);
310
+ sh('wscript.exe', [plan.files[0][0]]);
311
+ // schtasks' stderr is in the console's OEM code page (garbled when echoed); the usual cause is
312
+ // missing admin rights for /SC ONLOGON. The Startup-folder launcher starts at logon the same way.
313
+ log('schtasks needs administrator rights here (exit ' + r.status + '); installed the Startup-folder launcher instead (also starts at logon)');
314
+ }
315
+ } else if (plan.kind === 'launchd') {
316
+ log('launchctl load -w ' + plan.unit); if (!dry) sh('launchctl', ['load', '-w', plan.unit]);
317
+ serviceRestart(plan, dry, log, sh, uid);
318
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
319
+ if (plan.kind === 'cron') {
320
+ log('crontab: @reboot entry ' + plan.cronLine);
321
+ if (!dry) { const cur = readCron(sh); if (cur !== null) writeCron(sh, cur, plan.cronLine); }
322
+ } else {
323
+ log('WARNING: no systemd user session and no crontab here. Add this to your own init/boot script to start the server at boot: ' + cronStartCmd(plan.bin, plan.core, plan.log));
324
+ }
325
+ serviceRestart(plan, dry, log, sh, uid);
326
+ } else {
327
+ log('systemctl --user enable --now kenaz-server');
328
+ if (!dry) { sh('systemctl', ['--user', 'daemon-reload']); sh('systemctl', ['--user', 'enable', '--now', 'kenaz-server']); }
329
+ serviceRestart(plan, dry, log, sh, uid);
330
+ }
331
+ }
332
+ function serviceUninstall(plan, dry, log, run = sh) {
333
+ if (plan.kind === 'windows') {
334
+ log('schtasks /End + /Delete /TN ' + TASK_NAME);
335
+ if (!dry) { run('schtasks', ['/End', '/TN', TASK_NAME]); run('schtasks', ['/Delete', '/TN', TASK_NAME, '/F']); }
336
+ if (fs.existsSync(plan.fallbackFile)) { log('remove ' + plan.fallbackFile); if (!dry) fs.rmSync(plan.fallbackFile, { force: true }); }
337
+ } else if (plan.kind === 'launchd') {
338
+ log('launchctl unload ' + plan.unit); if (!dry) run('launchctl', ['unload', '-w', plan.unit]);
339
+ } else if (plan.kind === 'cron' || plan.kind === 'manual') {
340
+ if (plan.kind === 'cron') { log('crontab: remove the kenaz-server @reboot entry'); if (!dry) { const cur = readCron(run); if (cur !== null) writeCron(run, cur, null); } }
341
+ log('stop ' + plan.bin); if (!dry) run('sh', ['-c', stopCmd(plan)]);
342
+ } else {
343
+ log('systemctl --user disable --now kenaz-server'); if (!dry) run('systemctl', ['--user', 'disable', '--now', 'kenaz-server']);
344
+ }
345
+ for (const [f] of plan.files) if (fs.existsSync(f)) { log('remove ' + f); if (!dry) fs.rmSync(f, { force: true }); }
346
+ }
347
+ function serviceStatus(plan, run = sh) {
348
+ if (plan.kind === 'cron') { const c = readCron(run); return c && c.includes(CRON_MARK) ? 'cron @reboot entry present' : 'not installed'; }
349
+ if (plan.kind === 'manual') return 'manual (no systemd user session, no crontab)';
350
+ if (plan.kind === 'windows') return run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0 ? 'scheduled task present' : (fs.existsSync(plan.fallbackFile) ? 'startup launcher present' : 'not installed');
351
+ if (plan.kind === 'systemd') return run('systemctl', ['--user', 'is-active', 'kenaz-server']).stdout.trim() || 'not installed';
352
+ return fs.existsSync(plan.unit) ? 'plist present' : 'not installed';
353
+ }
354
+
355
+ /** Which mechanism keeps the server running on this machine (for `kenaz doctor`). Read-only, all via `run`. */
356
+ function detectServiceMode(home, core, run = sh, platform = process.platform) {
357
+ if (platform === 'win32') {
358
+ const plan = servicePlan(home, '', core, 'win32');
359
+ if (run('schtasks', ['/Query', '/TN', TASK_NAME]).status === 0) return { mode: 'windows task', detail: 'scheduled task ' + TASK_NAME };
360
+ if (fs.existsSync(plan.fallbackFile)) return { mode: 'startup launcher', detail: plan.fallbackFile };
361
+ return { mode: 'manual', detail: 'no scheduled task or Startup launcher; run `kenaz install`' };
362
+ }
363
+ if (platform === 'darwin') {
364
+ const p = servicePlan(home, '', core, 'darwin').unit;
365
+ return fs.existsSync(p) ? { mode: 'launchd', detail: p } : { mode: 'manual', detail: 'no launchd agent; run `kenaz install`' };
366
+ }
367
+ const unit = servicePlan(home, '', core, 'linux').unit;
368
+ if (fs.existsSync(unit) && probeLinux(run).systemd) return { mode: 'systemd', detail: unit };
369
+ const c = readCron(run);
370
+ if (c && c.includes(CRON_MARK)) return { mode: 'cron', detail: '@reboot crontab entry (no systemd user session)' };
371
+ return { mode: 'manual', detail: 'no systemd unit or cron entry; run `kenaz install`' };
372
+ }
373
+
374
+ function parseArgs(argv) {
375
+ const o = { cmd: argv[0], flags: {} };
376
+ for (let i = 1; i < argv.length; i++) {
377
+ const a = argv[i];
378
+ if (!a.startsWith('--')) continue;
379
+ const k = a.slice(2);
380
+ if (['home', 'core-dir', 'server-bin'].includes(k)) o.flags[k] = argv[++i]; else o.flags[k] = true;
381
+ }
382
+ return o;
383
+ }
384
+ function findServerBin(core, home) {
385
+ const exe = process.platform === 'win32' ? 'kenaz-server.exe' : 'kenaz-server';
386
+ const c = [path.join(core, '..', exe), path.join(core, 'bin', exe), path.join(core, 'dashboard', 'src-tauri', 'target', 'release', exe), path.join(home, '.kenaz', 'bin', exe)];
387
+ return c.map((p) => path.resolve(p)).find((p) => fs.existsSync(p)) || null;
388
+ }
389
+
390
+ function main(argv, out = console.log, getVersion = claudeVersion, deps = {}) { // getVersion, deps {sh, platform, uid, onPlan}: injectable for tests
391
+ const { cmd, flags } = parseArgs(argv);
392
+ if (!['install', 'uninstall', 'status'].includes(cmd)) {
393
+ out('usage: install-kenaz-cli.js <install|uninstall|status> [--dry-run] [--home d] [--core-dir d] [--server-bin p] [--no-service] [--no-plugin]');
394
+ return 2;
395
+ }
396
+ const dry = !!flags['dry-run'];
397
+ const home = path.resolve(flags.home || os.homedir());
398
+ const core = path.resolve(flags['core-dir'] || process.env.KENAZ_CORE_DIR || path.join(__dirname, '..', '..'));
399
+ // TASK_2380: kenaz-peer (idle push) rides along; same gate and merge rules as plugins/kenaz.
400
+ const pluginDirs = ['kenaz', 'kenaz-peer'].map((n) => path.join(core, 'plugins', n));
401
+ const settings = path.join(home, '.claude', 'settings.json');
402
+ const log = (m) => out((dry ? '[dry-run] ' : '') + m);
403
+ let rc = 0;
404
+
405
+ if (!flags['no-plugin']) {
406
+ try {
407
+ let ver;
408
+ for (const pluginDir of pluginDirs) {
409
+ const name = path.basename(pluginDir);
410
+ if (cmd === 'status') {
411
+ const list = entriesOf(loadSettings(settings));
412
+ out(name + ': ' + (list.some((e) => norm(e) === norm(pluginDir)) ? 'registered' : 'not registered') + ' (' + pluginDir + ')');
413
+ } else if (cmd === 'uninstall') {
414
+ log(name + ': ' + unregisterPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
415
+ if (name === 'kenaz') log('statusline: ' + unregisterStatusline(settings, dry));
416
+ } else if (process.env.KENAZ_SKIP_CLAUDE_PROVISION === '1') {
417
+ log(name + ': skipped (KENAZ_SKIP_CLAUDE_PROVISION=1)');
418
+ } else if (!fs.existsSync(path.join(pluginDir, 'hooks', 'hooks.json'))) {
419
+ log(name + ': skipped, ' + pluginDir + ' has no hooks/hooks.json'); rc = 1;
420
+ } else {
421
+ if (ver === undefined) ver = getVersion();
422
+ if (!ver) log(name + ': skipped, claude CLI not found (install it, then rerun install)');
423
+ else if (cmpVersion(ver, MIN_CLI) < 0) log(name + ': skipped, claude ' + ver.join('.') + ' < ' + MIN_CLI.join('.') + ' (run `claude update`, then rerun install)');
424
+ else {
425
+ log(name + ': ' + registerPlugin(settings, pluginDir, dry) + ' (' + settings + ')');
426
+ if (name === 'kenaz') {
427
+ const sl = registerStatusline(settings, core, dry);
428
+ log('statusline: ' + (sl === 'kept-user' ? 'kept your own statusLine; to show Kenaz task counts, append the output of: node "' + path.join(core, ...STATUSLINE_TAIL.split('/')) + '"' : sl));
429
+ const n = retireDashboardHooks(settings, dry);
430
+ if (n) log('kenaz: retired ' + n + ' Dashboard-era user-level hook(s), now owned by the plugin (backup: ' + path.join(path.dirname(settings), BACKUP) + ')');
431
+ }
432
+ }
433
+ }
434
+ }
435
+ } catch (e) { out('plugin: ERROR ' + e.message); rc = 1; }
436
+ }
437
+
438
+ if (!flags['no-service']) {
439
+ const bin = flags['server-bin'] ? path.resolve(flags['server-bin']) : findServerBin(core, home);
440
+ const run = deps.sh || sh, platform = deps.platform || process.platform;
441
+ // the probe is read-only but still a system call: skipped on --dry-run (assumes systemd, as before)
442
+ const caps = platform === 'linux' && !dry ? probeLinux(run) : undefined;
443
+ const plan = servicePlan(home, bin || '<kenaz-server not found>', core, platform, caps);
444
+ if (deps.onPlan) deps.onPlan(plan);
445
+ if (cmd === 'status') out('service: ' + (dry ? 'skipped (dry-run)' : serviceStatus(plan, run)));
446
+ else if (cmd === 'uninstall') serviceUninstall(plan, dry, log, run);
447
+ else if (!bin) { out('service: skipped, kenaz-server binary not found (pass --server-bin)'); rc = 1; }
448
+ else serviceInstall(plan, dry, log, run, deps.uid);
449
+ }
450
+ if (cmd === 'install' && rc === 0) out('Next: cd <your project>; claude; /kenaz:kenaz init; /kenaz:Ansuz (Codex: run /hooks once to trust the kenaz hooks, then $kenaz status)');
451
+ if (cmd === 'uninstall') out('Project .kenaz/ data was left untouched.');
452
+ return rc;
453
+ }
454
+
455
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
456
+ module.exports = { main, verifyRuntime, healthOnce, serviceInstall, serviceRestart, restartHint, claudeVersion, loadSettings, entriesOf, norm, MIN_CLI, registerPlugin, unregisterPlugin, registerStatusline, unregisterStatusline, retireDashboardHooks, servicePlan, detectServiceMode, probeLinux, cronLine, CRON_MARK, winVbs, launchdPlist, systemdUnit, parseVersion, cmpVersion };
@@ -5,6 +5,8 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
5
5
 
6
6
  你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
7
 
8
+ 用戶貼上房間 key 或要求加入/建立房間時,自己執行 `kenaz ygg join <key>` / `kenaz ygg create`(不要請用戶輸入),再轉述 status 區塊與「restart Claude Code」提示。
9
+
8
10
  ## Boot(一次 Bash)
9
11
  1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
10
12
  2. `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
@@ -1,5 +1,6 @@
1
1
  [Kenaz plugin: mode full] Kenaz MCP tools (kenaz-dashboard) are available when kenaz-server runs on localhost:18080.
2
2
  - Code navigation: kenaz_lsp_callers / kenaz_lsp_references / kenaz_lsp_definition / kenaz_lsp_symbols (always pass projectPath). Text search: Grep/Glob.
3
3
  - Collaboration: kenaz_peer_send / kenaz_peer_threads (other sessions, other machines, Codex), /kenaz ygg status|join.
4
+ - Rooms: when the user pastes a room key or asks to join/create a room, run `kenaz ygg join <key>` / `kenaz ygg create` yourself (never ask them to type it), then relay the status block and any "restart Claude Code" note.
4
5
  - Reply in the user's language. Commands: /Ansuz, /Amelia, /review, /security, /incident, /spec.
5
6
  - Switch: /kenaz off | assist | full (or env KENAZ_MODE). off disables every Kenaz hook.
@@ -51,6 +51,15 @@
51
51
  }
52
52
  ]
53
53
  },
54
+ {
55
+ "matcher": "mcp__.*kenaz_peer_send|Bash",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" peer-session-fill"
60
+ }
61
+ ]
62
+ },
54
63
  {
55
64
  "matcher": "Agent|Task",
56
65
  "hooks": [
@@ -33,6 +33,7 @@ const HOOKS = {
33
33
  'synapse-first': { kind: 'script', file: 'synapse-first-investigation.js', minMode: 'full' },
34
34
  'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
35
35
  'vendor-agent-guard': { kind: 'script', file: 'vendor-agent-guard.js', call: 'main', minMode: 'assist' },
36
+ 'peer-session-fill': { kind: 'script', file: 'peer-session-fill.js', call: 'main', minMode: 'assist' },
36
37
  'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
37
38
  };
38
39
 
@@ -5,6 +5,8 @@
5
5
  // /api/yggdrasil/config + /api/peer/status (same per-project/global key rules as the Dashboard,
6
6
  // TASK_2321/2349). k-cli `ygg` shells out to this file - the one implementation.
7
7
  const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
8
10
 
9
11
  const BASE = () => 'http://127.0.0.1:' + (process.env.KENAZ_HTTP_PORT || process.env.KENAZ_BACKEND_PORT || '18080');
10
12
 
@@ -86,6 +88,33 @@ function buildStatus(cfg, relay, peer) {
86
88
  };
87
89
  }
88
90
 
91
+ // TASK_2461: push needs the kenaz-peer plugin loaded by Claude Code (only in Ansuz sessions). join/create
92
+ // leave the node ready: register kenaz-peer in ~/.claude/settings.json when missing (same writer as the
93
+ // installer). Returns {state: 'ok'|'added'|'missing'|'skipped'|'error', note}. Never throws.
94
+ function loadInstaller() {
95
+ for (const p of ['../../../dashboard/scripts/install-kenaz-cli.js', '../../../packages/kenaz/vendor/install-kenaz-cli.js']) {
96
+ try { return require(path.join(__dirname, p)); } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') throw e; }
97
+ }
98
+ return null;
99
+ }
100
+ function peerPlugin(home, register, version) {
101
+ try {
102
+ const inst = loadInstaller();
103
+ if (!inst) return { state: 'skipped', note: 'installer not found' };
104
+ const settings = path.join(home, '.claude', 'settings.json');
105
+ if (!fs.existsSync(path.join(home, '.claude'))) return { state: 'skipped', note: 'claude is not installed' };
106
+ const ver = version === undefined ? inst.claudeVersion() : version;
107
+ if (ver && inst.cmpVersion(ver, inst.MIN_CLI) < 0) return { state: 'skipped', note: 'claude ' + ver.join('.') + ' < ' + inst.MIN_CLI.join('.') + ' (run `claude update`)' };
108
+ const core = path.resolve(process.env.KENAZ_CORE_DIR || path.join(__dirname, '..', '..', '..'));
109
+ const dir = path.join(core, 'plugins', 'kenaz-peer');
110
+ const list = inst.entriesOf(inst.loadSettings(settings));
111
+ if (list.some((e) => inst.norm(e) === inst.norm(dir))) return { state: 'ok', note: 'kenaz-peer plugin is registered' };
112
+ if (!register || !fs.existsSync(dir)) return { state: 'missing', note: 'kenaz-peer plugin is not registered in ' + settings + (fs.existsSync(dir) ? ' (run `kenaz ygg join <key>` or `kenaz install`)' : ' (run `npx kenaz@latest install`)') };
113
+ inst.registerPlugin(settings, dir, false);
114
+ return { state: 'added', note: 'registered the kenaz-peer plugin in ' + settings + '; restart Claude Code (new session) for it to load, then run /Ansuz for idle push' };
115
+ } catch (e) { return { state: 'error', note: 'could not check the kenaz-peer plugin: ' + e.message }; }
116
+ }
117
+
89
118
  function renderStatus(s) {
90
119
  const L = [
91
120
  'Yggdrasil room: ' + s.room,
@@ -102,14 +131,19 @@ function renderStatus(s) {
102
131
  'Last poll: ' + (s.last_poll_at ? s.last_poll_at + (s.last_poll_ok === false ? ' (FAILED)' : ' (ok)') : 'none yet'),
103
132
  'Delivery: ' + s.delivery_mode + (s.sessions.length ? '' : ' (no live sessions)'),
104
133
  ];
105
- for (const x of s.sessions) L.push(' - ' + x.agent + ' ' + String(x.session_id).slice(0, 8) + ': ' + x.delivery_mode);
134
+ for (const x of s.sessions) L.push(' ' + (x.name || x.agent) + ' (' + String(x.session_id).slice(0, 8) + ') - ' + x.delivery_mode);
106
135
  if (s.created) {
107
136
  L.push('', 'New team key (shown once, save it): ' + s.created.key + (s.created.team_name ? ' [' + s.created.team_name + ']' : ''));
108
137
  L.push('share with teammates: kenaz ygg join ' + s.created.key);
109
138
  }
110
139
  if (s.same_name_warning) L.push('WARNING: ' + s.same_name_warning);
111
140
  if (s.room_warning) L.push('WARNING: ' + s.room_warning);
112
- if (s.delivery_note) L.push('Note: ' + s.delivery_note);
141
+ const pl = s.plugin;
142
+ if (pl && ['added', 'missing', 'error'].includes(pl.state)) L.push('Push plugin: ' + pl.note);
143
+ // The server cannot know whether the plugin is registered; when this machine knows it is, do not repeat "register it".
144
+ const stale = pl && (pl.state === 'ok' || pl.state === 'added') && /register the kenaz-peer plugin|may not be registered/.test(s.delivery_note || '');
145
+ if (stale) L.push('Note: No push channel yet: the kenaz-peer plugin is registered but only serves Ansuz sessions. Run /Ansuz or /kenaz:Ansuz in this session' + (pl.state === 'added' ? ' after restarting Claude Code' : '') + ' for idle push; until then messages arrive on your next prompt.');
146
+ else if (s.delivery_note) L.push('Note: ' + s.delivery_note);
113
147
  return L.join('\n');
114
148
  }
115
149
 
@@ -200,7 +234,7 @@ async function leave(dir) {
200
234
  }
201
235
 
202
236
  async function main(argv) {
203
- const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false; let name = '';
237
+ const rest = []; let dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); let scope = 'project'; let json = false; let name = ''; let home = os.homedir();
204
238
  for (let i = 0; i < argv.length; i++) {
205
239
  const a = argv[i];
206
240
  if (a === '--project-path' && argv[i + 1]) dir = argv[++i];
@@ -208,6 +242,7 @@ async function main(argv) {
208
242
  else if (a === '--global') scope = 'global';
209
243
  else if (a === '--project') scope = 'project';
210
244
  else if (a === '--json') json = true;
245
+ else if (a === '--home' && argv[i + 1]) home = argv[++i];
211
246
  else if (a === '--name' && argv[i + 1]) name = argv[++i];
212
247
  else if (a.startsWith('--name=')) name = a.slice(7);
213
248
  else rest.push(a);
@@ -221,6 +256,7 @@ async function main(argv) {
221
256
  else if (sub === 'leave') s = await leave(dir);
222
257
  else if (sub === 'status') s = await status(dir);
223
258
  else throw new Error('unknown ygg subcommand: ' + sub + ' (join <key> | create [--name N] | status | leave)');
259
+ s.plugin = peerPlugin(path.resolve(home), sub === 'join' || sub === 'create');
224
260
  } catch (e) {
225
261
  console.error('ygg: ' + e.message + (/fetch failed|timeout|abort/i.test(e.message) ? ' (is the Kenaz backend running on ' + BASE() + '?)' : ''));
226
262
  process.exit(1);
@@ -228,5 +264,5 @@ async function main(argv) {
228
264
  console.log(json ? JSON.stringify(s, null, 2) : renderStatus(s));
229
265
  }
230
266
 
231
- module.exports = { nodeIsOnline, maskKey, buildStatus, renderStatus, join, createTeam };
267
+ module.exports = { peerPlugin, nodeIsOnline, maskKey, buildStatus, renderStatus, join, createTeam };
232
268
  if (require.main === module) main(process.argv.slice(2));
@@ -29,10 +29,10 @@ async function withBackend(persist, fn) {
29
29
  try { return await fn(srv.address().port); } finally { srv.close(); }
30
30
  }
31
31
 
32
- function cli(port, dir) {
32
+ function cli(port, dir, home = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg-home-'))) { // never the real ~/.claude
33
33
  return new Promise((resolve) => {
34
- const c = spawn(process.execPath, [YGG, 'join', 'ygk_abc', '--project-path', dir],
35
- { env: Object.assign({}, process.env, { KENAZ_HTTP_PORT: String(port) }) });
34
+ const c = spawn(process.execPath, [YGG, 'join', 'ygk_abc', '--project-path', dir].concat(['--home', home]),
35
+ { env: Object.assign({}, process.env, { KENAZ_HTTP_PORT: String(port), KENAZ_CORE_DIR: CORE }) });
36
36
  let out = ''; let err = '';
37
37
  c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { err += d; });
38
38
  c.on('close', (code) => resolve({ code, out, err }));
@@ -70,3 +70,63 @@ test('join refuses when the backend reports no effective_project_key (nothing to
70
70
  assert.match(r.err, /cannot work out this project's room/);
71
71
  } finally { srv.close(); }
72
72
  });
73
+
74
+ // TASK_2461: join leaves the node push-ready (kenaz-peer registered), touching only the --home it is given.
75
+ // a temp core: the checkout may itself be a .kenaz-worktrees path, which the installer refuses to register
76
+ const CORE = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-core-'));
77
+ const CORE_PEER = path.join(CORE, 'plugins', 'kenaz-peer');
78
+ fs.mkdirSync(CORE_PEER, { recursive: true });
79
+ function tempHome(settings) {
80
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
81
+ fs.mkdirSync(path.join(home, '.claude'));
82
+ const f = path.join(home, '.claude', 'settings.json');
83
+ fs.writeFileSync(f, settings);
84
+ return { home, f };
85
+ }
86
+
87
+ test('join registers a missing kenaz-peer in the temp settings.json, names the restart, and is idempotent', async () => {
88
+ const { home, f } = tempHome(JSON.stringify({ env: { CLAUDE_CODE_PLUGIN_DIRS: '/other/plugin' } }));
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
90
+ await withBackend(true, async (port) => {
91
+ const r = await cli(port, dir, home);
92
+ assert.strictEqual(r.code, 0, r.err);
93
+ assert.ok(JSON.parse(fs.readFileSync(f, 'utf8')).env.CLAUDE_CODE_PLUGIN_DIRS.split(path.delimiter).includes(CORE_PEER));
94
+ assert.match(r.out, /restart Claude Code/);
95
+ const after = fs.readFileSync(f, 'utf8');
96
+ const r2 = await cli(port, dir, home);
97
+ assert.strictEqual(r2.code, 0, r2.err);
98
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), after);
99
+ assert.doesNotMatch(r2.out, /restart Claude Code/);
100
+ });
101
+ });
102
+
103
+ test('join leaves settings.json byte-identical when kenaz-peer is already registered', async () => {
104
+ const txt = JSON.stringify({ env: { CLAUDE_CODE_PLUGIN_DIRS: CORE_PEER } });
105
+ const { home, f } = tempHome(txt);
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ygg2461-'));
107
+ await withBackend(true, async (port) => {
108
+ const r = await cli(port, dir, home);
109
+ assert.strictEqual(r.code, 0, r.err);
110
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), txt);
111
+ });
112
+ });
113
+
114
+ test('rendered status: registered but not Ansuz never says to register the plugin; unregistered says what to do', () => {
115
+ const { renderStatus, buildStatus } = require('./ygg.js');
116
+ const base = buildStatus({ effective_project_key: 'r', node_name: 'n', enabled: true, collaboration_projects: ['r'] }, { connected: true, nodes: [] },
117
+ { delivery_mode: 'pull-on-prompt', delivery_note: 'No push channel. No kenaz-peer plugin heartbeat seen: register the kenaz-peer plugin, then run /Ansuz' });
118
+ const ok = renderStatus(Object.assign({}, base, { plugin: { state: 'ok', note: 'x' } }));
119
+ assert.doesNotMatch(ok, /register the kenaz-peer plugin/);
120
+ assert.match(ok, /only serves Ansuz sessions/);
121
+ const miss = renderStatus(Object.assign({}, base, { plugin: { state: 'missing', note: 'kenaz-peer plugin is not registered in S (run `kenaz ygg join <key>` or `kenaz install`)' } }));
122
+ assert.match(miss, /Push plugin:.*not registered/);
123
+ });
124
+
125
+ test('TASK_2463: rendered status shows each session as name (short id) - mode', () => {
126
+ const { renderStatus, buildStatus } = require('./ygg.js');
127
+ const st = buildStatus({ effective_project_key: 'r', node_name: 'n', enabled: true, collaboration_projects: ['r'] }, { connected: true, nodes: [] },
128
+ { delivery_mode: 'push', sessions: [{ session_id: 'abcdef123456', agent: 'claude', name: 'my-work', delivery_mode: 'push' }, { session_id: 'zzzzzzzz9999', agent: 'codex', delivery_mode: 'pull-on-prompt' }] });
129
+ const out = renderStatus(st);
130
+ assert.match(out, /^ {2}my-work \(abcdef12\) - push$/m);
131
+ assert.match(out, /^ {2}codex \(zzzzzzzz\) - pull-on-prompt$/m);
132
+ });
@@ -2,14 +2,17 @@ import { test, expect, mock } from 'claude-code/testing'
2
2
 
3
3
  const msg = { id: 'm1', framed: '[Peer message] hello' }
4
4
 
5
- function setup(on: any, rows: () => unknown[]) {
5
+ function setup(on: any, rows: () => unknown[], marker?: () => string | null) {
6
6
  const calls: string[] = []
7
7
  let delivered = false
8
8
  on('session.start', async (_$: any, e: any) => ({ cwd: e.cwd }))
9
9
  on('session.id', async () => ({ value: 'sess-1' }))
10
10
  on('session.cwd', async () => ({ value: '/tmp/proj' }))
11
11
  on('session.messages', async () => ({ value: rows() }))
12
- on('fs.read', async () => ({ deny: 'no pin' }))
12
+ on('fs.read', async (_$: any, e: any) => {
13
+ const m = marker?.()
14
+ return String(e.path ?? e.file ?? '').endsWith('/sess-1.json') && m ? { value: m } : { deny: 'no pin' }
15
+ })
13
16
  on('http.fetch', async (_$: any, e: any) => {
14
17
  const url = String(e.url)
15
18
  calls.push(`${e.init?.method ?? 'GET'} ${url}`)
@@ -144,3 +147,57 @@ test('plugin-namespaced /kenaz:Ansuz also activates delivery', async ($, on) =>
144
147
  expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
145
148
  expect(submits.length).toBe(1)
146
149
  })
150
+
151
+ // TASK_2465: collaboration opt-in (marker written by peer-session-fill.js, or KENAZ_PEER_OPTIN=1 from kenaz launch).
152
+ const PLAIN = [{ role: 'user', content: 'hello there' }]
153
+ const MARKER_ENV = { CLAUDE_CODE_ENTRYPOINT: 'cli', KENAZ_PEER_OPTIN_DIR: '/m' }
154
+
155
+ test('TASK_2465 privacy: plain session without opt-in never heartbeats, peeks or submits', async ($, on) => {
156
+ const clock = mock.clock(on)
157
+ mock.env(on, MARKER_ENV)
158
+ const { calls, submits } = setup(on, () => PLAIN, () => null)
159
+ await $.session.start(START)
160
+ await clock.advance(15_000)
161
+ expect(calls.length).toBe(0)
162
+ expect(submits.length).toBe(0)
163
+ })
164
+
165
+ test('TASK_2465: opt-in marker makes a plain session heartbeat and receive push', async ($, on) => {
166
+ const clock = mock.clock(on)
167
+ mock.env(on, MARKER_ENV)
168
+ const { calls, submits } = setup(on, () => PLAIN, () => JSON.stringify({ ts: Date.now() }))
169
+ await $.session.start(START)
170
+ await clock.advance(5_000)
171
+ await clock.advance(5_000)
172
+ expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
173
+ expect(submits.length).toBe(1)
174
+ })
175
+
176
+ test('TASK_2465: an expired marker is not an opt-in', async ($, on) => {
177
+ const clock = mock.clock(on)
178
+ mock.env(on, MARKER_ENV)
179
+ const { calls, submits } = setup(on, () => PLAIN, () => JSON.stringify({ ts: Date.now() - 25 * 3600 * 1000 }))
180
+ await $.session.start(START)
181
+ await clock.advance(15_000)
182
+ expect(calls.length).toBe(0)
183
+ expect(submits.length).toBe(0)
184
+ })
185
+
186
+ test('TASK_2465: KENAZ_PEER_OPTIN=1 does not opt in a claude -p worker (sdk-cli)', async ($, on) => {
187
+ const clock = mock.clock(on)
188
+ mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'sdk-cli', KENAZ_PEER_OPTIN: '1' })
189
+ const { calls } = setup(on, () => PLAIN)
190
+ await $.session.start(START)
191
+ await clock.advance(15_000)
192
+ expect(calls.length).toBe(0)
193
+ })
194
+
195
+ test('TASK_2465: KENAZ_PEER_OPTIN=1 (kenaz launch) opts a plain session in', async ($, on) => {
196
+ const clock = mock.clock(on)
197
+ mock.env(on, { CLAUDE_CODE_ENTRYPOINT: 'cli', KENAZ_PEER_OPTIN: '1' })
198
+ const { calls } = setup(on, () => PLAIN)
199
+ await $.session.start(START)
200
+ await clock.advance(5_000)
201
+ await clock.advance(5_000)
202
+ expect(calls[0]).toContain('POST http://127.0.0.1:18080/api/peer/mod-heartbeat')
203
+ })
@@ -20,6 +20,8 @@ let cwd = ''
20
20
  let beaten = false
21
21
  let busy = false
22
22
  let ansuz = false // sticky: once a session is Ansuz it never flips back
23
+ let entrypoint = '' // CLAUDE_CODE_ENTRYPOINT; the env opt-in counts only for the interactive 'cli'
24
+ let optinLive = false // TASK_2465: opt-in marker seen on the last check (expires, so not sticky)
23
25
  // Same markers as .claude/hooks/_ansuz-conversation.js (turn-context's gate).
24
26
  const MARKERS = [
25
27
  '<command-name>/Ansuz</command-name>',
@@ -65,8 +67,28 @@ async function project($: any): Promise<string> {
65
67
  }
66
68
 
67
69
  // Only USER-role text counts (strings or text blocks); tool results do not.
70
+ // TASK_2465: opt-in marker written by .claude/hooks/peer-session-fill.js (kenaz_peer_send / ygg join|create) under
71
+ // <KENAZ_PEER_OPTIN_DIR | ~/.kenaz/peer-optin>/<session id>.json, or KENAZ_PEER_OPTIN=1 (kenaz launch). 24 h expiry.
72
+ const OPTIN_TTL_MS = 24 * 3600 * 1000
73
+ async function optedIn($: any): Promise<boolean> {
74
+ try {
75
+ if (entrypoint === 'cli' && (await $.env.get('KENAZ_PEER_OPTIN')) === '1') return true
76
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return false
77
+ let dir = (await $.env.get('KENAZ_PEER_OPTIN_DIR')) ?? ''
78
+ if (!dir) {
79
+ const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
80
+ if (!home) return false
81
+ dir = `${home}/.kenaz/peer-optin`
82
+ }
83
+ const f = await $.fs.read(`${dir}/${sessionId}.json`)
84
+ const age = Date.now() - Number(JSON.parse(typeof f === 'string' ? f : f.text).ts)
85
+ return age >= 0 && age < OPTIN_TTL_MS
86
+ } catch { return false }
87
+ }
88
+
68
89
  async function isAnsuz($: any): Promise<boolean> {
69
90
  if (ansuz) return true
91
+ if ((optinLive = await optedIn($))) return true
70
92
  try {
71
93
  const rows = await $.session.messages({ as: 'api' })
72
94
  for (const m of rows) {
@@ -163,10 +185,11 @@ export const register: Register = on => {
163
185
  ep = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? ''
164
186
  optIn = (await $.env.get('KENAZ_IN_SDK')) ?? ''
165
187
  } catch { /* env unreadable: behave as an interactive session */ }
188
+ entrypoint = ep
166
189
  if (ep.startsWith('sdk-') && ep !== 'sdk-cli' && optIn !== '1') return next(e)
167
190
  sessionId = String(await $.session.id())
168
191
  cwd = String(await $.session.cwd())
169
- $.clock.every(HEARTBEAT_MS, () => { if (ansuz) void beat($) })
192
+ $.clock.every(HEARTBEAT_MS, () => { if (ansuz || optinLive) void beat($) })
170
193
  $.clock.every(POLL_MS, () => { void cycle($) })
171
194
  return next(e)
172
195
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
5
5
  "bin": {
6
6
  "kenaz": "bin/kenaz.js"
@@ -20,11 +20,11 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.21",
24
- "@kenazai/server-darwin-x64": "0.3.21",
25
- "@kenazai/server-linux-arm64": "0.3.21",
26
- "@kenazai/server-linux-x64": "0.3.21",
27
- "@kenazai/server-win32-x64": "0.3.21"
23
+ "@kenazai/server-darwin-arm64": "0.3.22",
24
+ "@kenazai/server-darwin-x64": "0.3.22",
25
+ "@kenazai/server-linux-arm64": "0.3.22",
26
+ "@kenazai/server-linux-x64": "0.3.22",
27
+ "@kenazai/server-win32-x64": "0.3.22"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {