kenaz 0.3.17 → 0.3.18

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.
@@ -201,9 +201,24 @@ function isDeduped(normalized) {
201
201
  }
202
202
  }
203
203
 
204
+ // Every Bash call appends a line; uncapped it reached 11.7 MB (2026-10-12). Past MAX_LOG_BYTES keep the newest
205
+ // KEEP_LOG_BYTES (cut at a line boundary), so the readers still see recent history.
206
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
207
+ const KEEP_LOG_BYTES = 1024 * 1024;
208
+ function capLog(file) {
209
+ let size = 0;
210
+ try { size = fs.statSync(file).size; } catch { return; }
211
+ if (size <= MAX_LOG_BYTES) return;
212
+ const buf = fs.readFileSync(file);
213
+ const tail = buf.subarray(buf.length - KEEP_LOG_BYTES);
214
+ const nl = tail.indexOf(10);
215
+ fs.writeFileSync(file, nl >= 0 ? tail.subarray(nl + 1) : tail);
216
+ }
217
+
204
218
  function appendTelemetry(record) {
205
219
  try {
206
220
  fs.mkdirSync(path.dirname(NUDGES_PATH), { recursive: true });
221
+ capLog(NUDGES_PATH);
207
222
  fs.appendFileSync(NUDGES_PATH, JSON.stringify(record) + '\n', 'utf8');
208
223
  } catch {
209
224
  // best-effort
@@ -19,7 +19,7 @@ const { spawn, spawnSync } = require('child_process');
19
19
 
20
20
  // First = default. From GET https://api.deepseek.com/models on 2026-10-11 (V4 ids, 1M context); the V3 ids still
21
21
  // answer but are no longer listed. `kenaz launch` re-reads the context window from that endpoint at start.
22
- const DEEPSEEK_MODELS = ['deepseek-v4-pro', 'deepseek-flash', 'deepseek-chat', 'deepseek-reasoner'];
22
+ const DEEPSEEK_MODELS = ['deepseek-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner']; // flash = default (user decision 2026-10-12)
23
23
  const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/anthropic';
24
24
  const DEEPSEEK_CONTEXT_TOKENS = { 'deepseek-v4-pro': 1048576, 'deepseek-flash': 1048576, 'deepseek-chat': 128000, 'deepseek-reasoner': 128000 };
25
25
  const DEEPSEEK_FAST = 'deepseek-flash'; // Claude Code's small/fast slot (titles, summaries): the cheap model
@@ -38,12 +38,37 @@ function parseExecutorModel(raw) {
38
38
  return { adapter, id };
39
39
  }
40
40
  if (adapter === 'deepseek') {
41
- if (!DEEPSEEK_MODELS.includes(id)) throw new Error(`unknown deepseek model '${id}'; valid: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
42
- return { adapter, id };
41
+ // Any id the vendor may add later is accepted (no release needed); `deepseek:` alone = the user's saved default.
42
+ const model = id || providerDefault('deepseek') || DEEPSEEK_MODELS[0];
43
+ if (!MODEL_ID.test(model)) throw new Error(`invalid deepseek model id '${id}' (letters, digits, . _ - only); known: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
44
+ return { adapter, id: model };
43
45
  }
44
46
  throw new Error(`unknown executor prefix '${m[1]}:'; valid prefixes: codex:<id>, deepseek:<model>`);
45
47
  }
46
48
 
49
+ const MODEL_ID = /^[A-Za-z0-9][\w.-]{0,63}$/;
50
+ const configFile = (home) => path.join(home, '.kenaz', 'config.json');
51
+
52
+ /** The user's own default model for a provider: KENAZ_<PROVIDER>_MODEL, else ~/.kenaz/config.json
53
+ * providers.<provider>.model (written by `kenaz launch --provider p --model m --save-default`); null when unset. */
54
+ function providerDefault(provider, env = process.env, home = os.homedir()) {
55
+ const e = env['KENAZ_' + provider.toUpperCase() + '_MODEL'];
56
+ if (e && e.trim()) return e.trim();
57
+ try { const m = JSON.parse(fs.readFileSync(configFile(home), 'utf8')).providers[provider].model; return typeof m === 'string' && m.trim() ? m.trim() : null; } catch { return null; }
58
+ }
59
+
60
+ /** Save the default (other keys in config.json are kept; atomic write). */
61
+ function saveProviderDefault(provider, model, home = os.homedir()) {
62
+ let cfg = {};
63
+ try { cfg = JSON.parse(fs.readFileSync(configFile(home), 'utf8')) || {}; } catch { /* new file */ }
64
+ cfg.providers = { ...(cfg.providers || {}), [provider]: { ...((cfg.providers || {})[provider] || {}), model } };
65
+ fs.mkdirSync(path.dirname(configFile(home)), { recursive: true });
66
+ const tmp = configFile(home) + '.tmp';
67
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
68
+ fs.renameSync(tmp, configFile(home));
69
+ return configFile(home);
70
+ }
71
+
47
72
  /** DEEPSEEK_API_KEY, else deepseek_api_key in ~/.kenaz/credentials.yaml; null when neither. */
48
73
  function deepseekKey(env = process.env, home = os.homedir()) {
49
74
  if (env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY.trim()) return env.DEEPSEEK_API_KEY.trim();
@@ -175,4 +200,4 @@ async function main(argv) {
175
200
  }
176
201
 
177
202
  if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
178
- module.exports = { otherVendorSession, parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
203
+ module.exports = { otherVendorSession, providerDefault, saveProviderDefault, MODEL_ID, parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  /**
4
- * `kenaz launch --provider deepseek [--model deepseek-v4-pro|deepseek-flash|...] [claude args...]`
4
+ * `kenaz launch --provider deepseek [--model deepseek-flash (default)|deepseek-v4-pro|...] [claude args...]`
5
5
  * Starts Claude Code with every model request sent to another vendor's Anthropic-compatible endpoint, so
6
6
  * Ansuz (/kenaz:Ansuz), its subagents and background requests run on that vendor and use no Claude quota.
7
7
  * The key is read from the env or ~/.kenaz/credentials.yaml and set only in the claude child's env: never in
@@ -9,7 +9,7 @@
9
9
  */
10
10
  const os = require('os');
11
11
  const { spawn } = require('child_process');
12
- const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
12
+ const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote, providerDefault, saveProviderDefault, MODEL_ID } = require('./exec');
13
13
 
14
14
  const PROVIDERS = {
15
15
  deepseek: {
@@ -17,29 +17,31 @@ const PROVIDERS = {
17
17
  keyHelp: 'set DEEPSEEK_API_KEY, or add `deepseek_api_key: <key>` to ~/.kenaz/credentials.yaml',
18
18
  },
19
19
  };
20
- const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id>] [claude args...]';
20
+ const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id> [--save-default]] [claude args...]';
21
21
 
22
22
  function parse(argv) {
23
- const rest = []; let provider = null; let model = null;
23
+ const rest = []; let provider = null; let model = null; let save = false;
24
24
  for (let i = 0; i < argv.length; i++) {
25
+ if (argv[i] === '--save-default') { save = true; continue; }
25
26
  const m = /^--(provider|model)(?:=(.*))?$/.exec(argv[i]);
26
27
  if (!m) { rest.push(argv[i]); continue; }
27
28
  const v = m[2] !== undefined ? m[2] : argv[++i];
28
29
  if (m[1] === 'provider') provider = v; else model = v;
29
30
  }
30
- return { rest, provider, model };
31
+ return { rest, provider, model, save };
31
32
  }
32
33
 
33
- /** Child process plan: { bin, args, env }. live = {id: context_window} from the vendor's model list (or null):
34
- * a listed id is valid even if it is newer than the built-in list, and its window drives auto-compact.
35
- * Throws with a user-facing message. */
34
+ /** Child process plan: { bin, args, env }. Model = --model, else the user's own default (KENAZ_<P>_MODEL or
35
+ * ~/.kenaz/config.json, see providerDefault), else the built-in default. live = {id: context_window} from the
36
+ * vendor's model list (or null): when known it is the only validation (new vendor models work without a Kenaz
37
+ * release); when unreachable any well-formed id is accepted. Throws with a user-facing message. */
36
38
  function plan(argv, env = process.env, home = os.homedir(), live = null) {
37
39
  const { rest, provider, model } = parse(argv);
38
40
  const p = PROVIDERS[provider];
39
41
  if (!p) throw new Error((provider ? `unknown provider '${provider}'. ` : '') + USAGE);
40
- const id = model || p.models[0];
41
- const valid = [...new Set([...p.models, ...Object.keys(live || {})])];
42
- if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
42
+ const id = model || providerDefault(provider, env, home) || p.models[0];
43
+ if (!MODEL_ID.test(id)) throw new Error(`invalid ${provider} model id '${id}'`);
44
+ if (live && !(id in live)) throw new Error(`unknown ${provider} model '${id}'; the vendor currently lists: ${Object.keys(live).join(', ')}`);
43
45
  const key = p.key(env, home);
44
46
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
45
47
  const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
@@ -56,6 +58,11 @@ async function main(argv, out = console.log, deps = {}) {
56
58
  const live = key ? await (deps.live || p.live)(key) : null;
57
59
  let pl;
58
60
  try { pl = plan(argv, env, home, live); } catch (e) { out(e.message); return 2; }
61
+ const { model: asked, save } = parse(argv);
62
+ if (save) {
63
+ if (!asked) { out('--save-default needs --model <id>'); return 2; }
64
+ out(`kenaz launch: ${pl.provider} default model saved: ${pl.model} (${saveProviderDefault(pl.provider, pl.model, home)})`);
65
+ }
59
66
  out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}, context ${pl.context} tokens); no Claude quota is used in this session`);
60
67
  const win = (deps.platform || process.platform) === 'win32'; // claude is a .cmd shim on npm installs
61
68
  const child = (deps.spawn || spawn)(win ? [pl.bin, ...pl.args].map(winQuote).join(' ') : pl.bin, win ? [] : pl.args,
@@ -25,7 +25,7 @@ test('deepseek: every model slot points at DeepSeek, key only in the child env,
25
25
  assert.strictEqual(e.ANTHROPIC_BASE_URL, 'https://api.deepseek.com/anthropic');
26
26
  assert.strictEqual(e.ANTHROPIC_AUTH_TOKEN, KEY);
27
27
  for (const k of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
28
- assert.strictEqual(e[k], 'deepseek-v4-pro', k);
28
+ assert.strictEqual(e[k], 'deepseek-flash', k);
29
29
  }
30
30
  assert.strictEqual(e.ANTHROPIC_SMALL_FAST_MODEL, 'deepseek-flash'); assert.strictEqual(e.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'deepseek-flash');
31
31
  assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
@@ -54,7 +54,7 @@ test('control: no key, unknown provider or model -> exit 2 and claude is never s
54
54
  [['--provider', 'deepseek'], {}, /no API key/],
55
55
  [['--provider', 'nope'], { DEEPSEEK_API_KEY: KEY }, /unknown provider 'nope'/],
56
56
  [[], { DEEPSEEK_API_KEY: KEY }, /usage: kenaz launch --provider <deepseek>/],
57
- [['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-v4-pro, deepseek-flash, deepseek-chat, deepseek-reasoner/],
57
+ [['--provider', 'deepseek', '--model', 'bad id'], { DEEPSEEK_API_KEY: KEY }, /invalid deepseek model id/],
58
58
  ]) {
59
59
  const calls = [], lines = [];
60
60
  const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env, home: home(), platform: 'linux' });
@@ -74,3 +74,27 @@ test('live model list: a newer listed id is accepted and its context window driv
74
74
  assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '2097152');
75
75
  assert.match(lines[0], /context 2097152 tokens/);
76
76
  });
77
+
78
+ test('model not in the vendor list is refused only when the list is known; unknown list = accepted (no release needed)', async () => {
79
+ const calls = [], lines = [];
80
+ const rc = await main(['--provider', 'deepseek', '--model', 'gpt-x'], (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => ({ 'deepseek-flash': 1048576 }), env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' });
81
+ assert.strictEqual(rc, 2); assert.strictEqual(calls.length, 0); assert.match(lines.join(String.fromCharCode(10)), /the vendor currently lists: deepseek-flash/);
82
+ const calls2 = [];
83
+ assert.strictEqual(await main(['--provider', 'deepseek', '--model', 'deepseek-v9'], () => {}, { spawn: fakeSpawn(calls2), live: async () => null, env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' }), 0);
84
+ assert.strictEqual(calls2[0].opts.env.ANTHROPIC_MODEL, 'deepseek-v9');
85
+ });
86
+
87
+ test('the user picks the default: --save-default writes ~/.kenaz/config.json (other keys kept), env overrides it, --model overrides both', async () => {
88
+ const h = home(); fs.mkdirSync(path.join(h, '.kenaz'));
89
+ fs.writeFileSync(path.join(h, '.kenaz', 'config.json'), JSON.stringify({ setupCompleted: true }));
90
+ const run = async (argv, env = {}) => { const calls = [], lines = []; const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env: { DEEPSEEK_API_KEY: KEY, ...env }, home: h, platform: 'linux' }); return { rc, model: calls[0] && calls[0].opts.env.ANTHROPIC_MODEL, lines }; };
91
+ assert.strictEqual((await run(['--provider', 'deepseek'])).model, 'deepseek-flash', 'built-in default');
92
+ const saved = await run(['--provider', 'deepseek', '--model', 'deepseek-v4-pro', '--save-default']);
93
+ assert.strictEqual(saved.model, 'deepseek-v4-pro'); assert.match(saved.lines.join(String.fromCharCode(10)), /default model saved: deepseek-v4-pro/);
94
+ const cfg = JSON.parse(fs.readFileSync(path.join(h, '.kenaz', 'config.json'), 'utf8'));
95
+ assert.strictEqual(cfg.setupCompleted, true); assert.strictEqual(cfg.providers.deepseek.model, 'deepseek-v4-pro');
96
+ assert.strictEqual((await run(['--provider', 'deepseek'])).model, 'deepseek-v4-pro', 'saved default used');
97
+ assert.strictEqual((await run(['--provider', 'deepseek'], { KENAZ_DEEPSEEK_MODEL: 'deepseek-chat' })).model, 'deepseek-chat', 'env overrides saved');
98
+ assert.strictEqual((await run(['--provider', 'deepseek', '--model', 'deepseek-flash'])).model, 'deepseek-flash', '--model overrides');
99
+ assert.strictEqual((await run(['--provider', 'deepseek', '--save-default'])).rc, 2, '--save-default without --model');
100
+ });
@@ -0,0 +1,42 @@
1
+ // dispatch.js without --project-path, run from a user's project through the plugin (cwd = that project, ROOT = core):
2
+ // the card and the worktree belong to the cwd project, not the core (repro 2026-10-12: '任務不存在' and a worktree
3
+ // created inside the core repo, leaving Ansuz waiting on a subagent that had no card).
4
+ const test = require('node:test');
5
+ const assert = require('node:assert');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+
11
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
12
+ const DISPATCH = path.join(ROOT, '.formation', 'tools', 'dispatch.js');
13
+ const git = (cwd, args) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, encoding: 'utf8' });
14
+
15
+ function project() {
16
+ const p = fs.mkdtempSync(path.join(os.tmpdir(), 'dcp-'));
17
+ git(p, ['init', '-q']); fs.writeFileSync(path.join(p, 'README.md'), 'x'); git(p, ['add', '-A']); git(p, ['commit', '-q', '-m', 'init']);
18
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(p, '.kenaz', 'tasks', d), { recursive: true });
19
+ fs.writeFileSync(path.join(p, '.kenaz', 'project.yaml'), 'name: dcp\n');
20
+ fs.writeFileSync(path.join(p, '.kenaz', 'tasks', 'backlog', 'TASK_7201_x.yaml'),
21
+ 'id: TASK_7201\ntitle: "Unique card title 7201"\nstatus: backlog\npriority: low\nrigor: low\nrequirements: ["r"]\nacceptance_criteria: ["a"]\nscope_paths: [x.txt]\n');
22
+ return p;
23
+ }
24
+
25
+ const env = () => { const e = { ...process.env }; for (const k of ['CLAUDE_PROJECT_DIR', 'KENAZ_PROVIDER', 'ANTHROPIC_BASE_URL', 'CODEX_SESSION_ID', 'CODEX_THREAD_ID']) delete e[k]; return e; };
26
+
27
+ test('from a user project without --project-path: the card is found and isolation targets that project', () => {
28
+ const p = project();
29
+ const r = spawnSync(process.execPath, [DISPATCH, '--skill=do', '--task=TASK_7201', '--dispatch-mode=subagent', '--dry-run'], { cwd: p, env: env(), encoding: 'utf8' });
30
+ assert.strictEqual(r.status, 0, r.stderr);
31
+ assert.doesNotMatch(r.stderr, /任務不存在/);
32
+ const j = JSON.parse(r.stdout);
33
+ assert.match(j.promptText, /Unique card title 7201/);
34
+ assert.match(r.stderr, new RegExp('cross-project detected .*' + path.basename(p)));
35
+ });
36
+
37
+ test('control: in the core repo itself nothing changes (same-project Agent worktree)', () => {
38
+ const r = spawnSync(process.execPath, [DISPATCH, '--skill=do', '--task=TASK_2376', '--dispatch-mode=subagent', '--dry-run'], { cwd: ROOT, env: env(), encoding: 'utf8' });
39
+ assert.strictEqual(r.status, 0, r.stderr);
40
+ const j = JSON.parse(r.stdout);
41
+ assert.strictEqual(j.isolation.mode, 'agent_tool_worktree');
42
+ });
@@ -139,8 +139,9 @@ test('subagent set: unknown codex id and unknown prefix are refused with the val
139
139
  const p = tmp(t, 'dm-');
140
140
  const a = dmRun(['set', 'codex:nope'], p);
141
141
  assert.equal(a.code, 1); assert.match(a.out, /unknown codex model 'nope'.*codex:gpt-5\.6-sol/);
142
- const b = dmRun(['set', 'deepseek:gpt'], p);
143
- assert.equal(b.code, 1); assert.match(b.out, /deepseek:deepseek-chat, deepseek:deepseek-reasoner/);
142
+ // any well-formed deepseek id is accepted (a new vendor model needs no Kenaz release); a malformed one is refused
143
+ const b = dmRun(['set', 'deepseek:bad id'], p);
144
+ assert.equal(b.code, 1); assert.match(b.out, /invalid deepseek model id/);
144
145
  const c = dmRun(['set', 'gemini:pro'], p);
145
146
  assert.equal(c.code, 1); assert.match(c.out, /unknown executor prefix 'gemini:'.*codex:<id>, deepseek:<model>/);
146
147
  assert.equal(fs.existsSync(path.join(p, '.kenaz', 'dashboard-models.json')), false);
@@ -487,6 +487,15 @@ function samePath(a, b) {
487
487
  * @param {boolean|null} [opts.useWorktreeOverride] - dispatch-time toggle (CLI --use-worktree)
488
488
  * @returns {{ isCrossProject: boolean, mode: string, useWorktree: boolean, source: string, verifyOffline: string|null }}
489
489
  */
490
+ /** The Kenaz project around the cwd (CLAUDE_PROJECT_DIR first), or null when it is the core itself / none. */
491
+ function cwdProject(start = process.env.CLAUDE_PROJECT_DIR || process.cwd()) {
492
+ let root = null;
493
+ try { root = require('../../.claude/hooks/_project-guard').findProjectRoot(start); } catch { return null; }
494
+ if (!root) return null;
495
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
496
+ return norm(root) === norm(ROOT) ? null : root;
497
+ }
498
+
490
499
  function resolveDispatchIsolation({ projectPath = null, useWorktreeOverride = null } = {}) {
491
500
  // TASK_1710 semantics: samePath, not raw string compare (separator/case).
492
501
  const isCrossProject = !!(projectPath && !samePath(projectPath, ROOT));
@@ -829,7 +838,11 @@ Sub-agent dispatch (Ansuz billing-aware path):
829
838
  const formation = getArg('--formation') || 'unit';
830
839
  const skill = getArg('--skill');
831
840
  const task = getArg('--task');
832
- const projectPath = getArg('--project-path');
841
+ // No --project-path: the Kenaz project around the cwd, when that is not the core itself. Plugin users run
842
+ // dispatch.js from their own project through core-resolve, so ROOT is the core: before this, the card was looked
843
+ // up in the core ("任務不存在") and the worktree was created in the core repo, leaving Ansuz waiting on a
844
+ // subagent with no card in the wrong repo (repro 2026-10-12).
845
+ const projectPath = getArg('--project-path') || cwdProject();
833
846
  const dryRun = args.includes('--dry-run');
834
847
  const dispatchModeCli = getArg('--dispatch-mode');
835
848
  // TASK_1998 R3: subagent-path model override — wins over dashboard
@@ -1 +1 @@
1
- 0.3.17
1
+ 0.3.18
@@ -115,6 +115,24 @@ function renderCompilerErrors(stdoutText, maxErrors = 20) {
115
115
  return out;
116
116
  }
117
117
 
118
+ // The shared verify cache only grows (incremental builds are never pruned): it reached 551 GB by 2026-10-12.
119
+ // Every KENAZ_VERIFY_CLEAN_DAYS (default 7) days run `cargo clean` on it first. A stamp file, not a size walk:
120
+ // walking hundreds of GB takes minutes. cargo clean takes cargo's own lock, so a running build is waited for.
121
+ const CLEAN_STAMP = '.kenaz-last-clean';
122
+ function periodicClean(targetDir, env, { now = Date.now(), run = spawnSync, log = (m) => process.stderr.write(m) } = {}) {
123
+ const days = Number(env.KENAZ_VERIFY_CLEAN_DAYS || 7);
124
+ const stamp = path.join(targetDir, CLEAN_STAMP);
125
+ let last = 0;
126
+ try { last = fs.statSync(stamp).mtimeMs; } catch (_) { /* never stamped */ }
127
+ const touch = () => { try { fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(stamp, new Date(now).toISOString() + '\n'); } catch (_) { /* best effort */ } };
128
+ if (!last) { touch(); return 'stamped'; } // first run after this change: start the clock, do not wipe
129
+ if (!(days > 0) || now - last < days * 86400000) return 'fresh';
130
+ log(`[run-rust-tests] verify cache last cleaned ${Math.round((now - last) / 86400000)} days ago: cargo clean (one full rebuild follows)\n`);
131
+ const r = run('cargo', ['clean'], { cwd: crateDir, env, encoding: 'utf8', shell: false });
132
+ touch();
133
+ return r && r.status === 0 ? 'cleaned' : 'clean-failed';
134
+ }
135
+
118
136
  function main() {
119
137
  const exeArgs = buildExeArgs(process.argv.slice(2));
120
138
 
@@ -131,6 +149,8 @@ function main() {
131
149
  );
132
150
  }
133
151
 
152
+ if (env.CARGO_TARGET_DIR === verifyTargetDir()) periodicClean(env.CARGO_TARGET_DIR, env);
153
+
134
154
  // 1. Build the lib test binary without running it.
135
155
  const build = spawnSync(
136
156
  'cargo',
@@ -204,4 +224,4 @@ if (require.main === module) {
204
224
  main();
205
225
  }
206
226
 
207
- module.exports = { parseArgs, buildExeArgs, renderCompilerErrors };
227
+ module.exports = { parseArgs, buildExeArgs, renderCompilerErrors, periodicClean, CLEAN_STAMP };
@@ -152,3 +152,20 @@ test('renderCompilerErrors: cap applied -> first N kept + omitted-count note', (
152
152
  'error[E0001]: synthetic error 1\n... 2 more errors omitted\n'
153
153
  );
154
154
  });
155
+
156
+ test('periodicClean: first run only stamps; fresh = nothing; older than N days = cargo clean then restamp', () => {
157
+ const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
158
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rrt-clean-'));
159
+ const calls = [];
160
+ const run = (cmd, args) => { calls.push([cmd, ...args].join(' ')); return { status: 0 }; };
161
+ const t0 = Date.parse('2026-10-01T00:00:00Z');
162
+ assert.equal(mod.periodicClean(dir, {}, { now: t0, run, log: () => {} }), 'stamped');
163
+ assert.deepEqual(calls, [], 'never wipes on the first run');
164
+ const stamp = path.join(dir, mod.CLEAN_STAMP);
165
+ fs.utimesSync(stamp, t0 / 1000, t0 / 1000);
166
+ assert.equal(mod.periodicClean(dir, {}, { now: t0 + 6 * 86400000, run, log: () => {} }), 'fresh');
167
+ assert.deepEqual(calls, []);
168
+ assert.equal(mod.periodicClean(dir, {}, { now: t0 + 8 * 86400000, run, log: () => {} }), 'cleaned');
169
+ assert.deepEqual(calls, ['cargo clean']);
170
+ assert.equal(mod.periodicClean(dir, { KENAZ_VERIFY_CLEAN_DAYS: '30' }, { now: t0 + 10 * 86400000, run, log: () => {} }), 'fresh', 'restamped, and the window is configurable');
171
+ });
@@ -76,6 +76,10 @@ function buildStatus(cfg, relay, peer) {
76
76
  waiting_mail: peer && typeof peer.waiting_count === 'number' ? peer.waiting_count : null,
77
77
  undeliverable_mail: peer && typeof peer.undeliverable_count === 'number' ? peer.undeliverable_count : null,
78
78
  // TASK_2380: real delivery mode per live session (push | pull-on-prompt | none), never a bare 'enabled'.
79
+ // TASK_2436: when the poller last reached the relay, and the relay's same-name warning.
80
+ last_poll_at: peer && peer.last_poll_at ? peer.last_poll_at : null,
81
+ last_poll_ok: peer && typeof peer.last_poll_ok === 'boolean' ? peer.last_poll_ok : null,
82
+ same_name_warning: peer && peer.same_name_warning ? peer.same_name_warning : '',
79
83
  delivery_mode: peer && peer.delivery_mode ? peer.delivery_mode : 'unknown',
80
84
  delivery_note: peer && peer.delivery_note ? peer.delivery_note : '',
81
85
  sessions: peer && Array.isArray(peer.sessions) ? peer.sessions : [],
@@ -95,6 +99,7 @@ function renderStatus(s) {
95
99
  'Pending mail: ' + (s.pending_mail == null ? 'unknown' : s.pending_mail),
96
100
  'Waiting for a session: ' + (s.waiting_mail == null ? 'unknown' : s.waiting_mail) + ' (stored on this node, readable)',
97
101
  'Undeliverable: ' + (s.undeliverable_mail == null ? 'unknown' : s.undeliverable_mail),
102
+ 'Last poll: ' + (s.last_poll_at ? s.last_poll_at + (s.last_poll_ok === false ? ' (FAILED)' : ' (ok)') : 'none yet'),
98
103
  'Delivery: ' + s.delivery_mode + (s.sessions.length ? '' : ' (no live sessions)'),
99
104
  ];
100
105
  for (const x of s.sessions) L.push(' - ' + x.agent + ' ' + String(x.session_id).slice(0, 8) + ': ' + x.delivery_mode);
@@ -102,6 +107,7 @@ function renderStatus(s) {
102
107
  L.push('', 'New team key (shown once, save it): ' + s.created.key + (s.created.team_name ? ' [' + s.created.team_name + ']' : ''));
103
108
  L.push('share with teammates: kenaz ygg join ' + s.created.key);
104
109
  }
110
+ if (s.same_name_warning) L.push('WARNING: ' + s.same_name_warning);
105
111
  if (s.room_warning) L.push('WARNING: ' + s.room_warning);
106
112
  if (s.delivery_note) L.push('Note: ' + s.delivery_note);
107
113
  return L.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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.17",
24
- "@kenazai/server-darwin-x64": "0.3.17",
25
- "@kenazai/server-linux-arm64": "0.3.17",
26
- "@kenazai/server-linux-x64": "0.3.17",
27
- "@kenazai/server-win32-x64": "0.3.17"
23
+ "@kenazai/server-darwin-arm64": "0.3.18",
24
+ "@kenazai/server-darwin-x64": "0.3.18",
25
+ "@kenazai/server-linux-arm64": "0.3.18",
26
+ "@kenazai/server-linux-x64": "0.3.18",
27
+ "@kenazai/server-win32-x64": "0.3.18"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {