tokenmaw 0.3.0 → 0.4.1

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.
@@ -0,0 +1,332 @@
1
+ /**
2
+ * update-check.ts — Update notification and self-update via npm.
3
+ *
4
+ * On startup the CLI queries `https://registry.npmjs.org/<pkg>/latest`, compares
5
+ * it against the running version, and (when newer) returns a notice that the
6
+ * CLI prints to stderr after command output. Results are cached for a day in
7
+ * the shared CODER_DATA_HOME cache dir so the registry is not hit on every run.
8
+ *
9
+ * In interactive sessions the CLI offers a y/N prompt; on confirmation it runs
10
+ * `npm install -g <pkg>@latest` for you. The install prefers the China mirror
11
+ * (registry.npmmirror.com) and falls back to the default registry when the
12
+ * mirror is unreachable or the install fails. Development installs (`npm link`)
13
+ * are never touched.
14
+ *
15
+ * Opt out of checks with MAW_NO_UPDATE_CHECK / CODER_NO_UPDATE_CHECK /
16
+ * NO_UPDATE_NOTIFIER, or implicitly in CI and non-TTY sessions. Opt out of
17
+ * self-updates with MAW_NO_SELF_UPDATE / CODER_NO_SELF_UPDATE.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { homedir } from 'node:os';
21
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
22
+ import { dirname, resolve, sep } from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { createInterface } from 'node:readline';
25
+ import { atomicReplaceFile } from './runtime/file-lock.js';
26
+ const require = createRequire(import.meta.url);
27
+ const pkg = require('../package.json');
28
+ const PKG_NAME = pkg.name ?? 'tokenmaw';
29
+ const REGISTRY_URL = 'https://registry.npmjs.org';
30
+ const NPM_MIRROR_REGISTRY = 'https://registry.npmmirror.com';
31
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
32
+ const REQUEST_TIMEOUT_MS = 3_000;
33
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
34
+ const PROMPT_TIMEOUT_MS = 30_000;
35
+ function cacheFilePath() {
36
+ const base = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder');
37
+ return resolve(base, 'cache', 'update-check.json');
38
+ }
39
+ function updateCheckDisabled() {
40
+ // Note: CI is intentionally NOT checked here. The library must stay testable
41
+ // under test runners that set CI=true; the CLI call site gates on isTTY,
42
+ // which already suppresses notices on non-interactive environments.
43
+ return ['MAW_NO_UPDATE_CHECK', 'CODER_NO_UPDATE_CHECK', 'NO_UPDATE_NOTIFIER']
44
+ .some((name) => {
45
+ const value = process.env[name];
46
+ return value !== undefined && value !== '0' && value.toLowerCase() !== 'false';
47
+ });
48
+ }
49
+ export function parseSemver(version) {
50
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version.trim());
51
+ if (!match)
52
+ return null;
53
+ return {
54
+ major: Number(match[1]),
55
+ minor: Number(match[2]),
56
+ patch: Number(match[3]),
57
+ prerelease: match[4] ? match[4].split('.') : [],
58
+ };
59
+ }
60
+ /** Semver ordering: negative when a < b, positive when a > b, 0 when equal. */
61
+ export function compareSemver(a, b) {
62
+ const pa = parseSemver(a);
63
+ const pb = parseSemver(b);
64
+ if (!pa || !pb)
65
+ return 0;
66
+ for (const part of ['major', 'minor', 'patch']) {
67
+ if (pa[part] !== pb[part])
68
+ return pa[part] > pb[part] ? 1 : -1;
69
+ }
70
+ const [preA, preB] = [pa.prerelease, pb.prerelease];
71
+ if (preA.length === 0 && preB.length === 0)
72
+ return 0;
73
+ if (preA.length === 0)
74
+ return 1;
75
+ if (preB.length === 0)
76
+ return -1;
77
+ const len = Math.max(preA.length, preB.length);
78
+ for (let i = 0; i < len; i++) {
79
+ const x = preA[i];
80
+ const y = preB[i];
81
+ if (x === undefined)
82
+ return -1;
83
+ if (y === undefined)
84
+ return 1;
85
+ if (x === y)
86
+ continue;
87
+ const xNumeric = /^\d+$/.test(x);
88
+ const yNumeric = /^\d+$/.test(y);
89
+ if (xNumeric && yNumeric)
90
+ return Number(x) > Number(y) ? 1 : -1;
91
+ if (xNumeric)
92
+ return -1;
93
+ if (yNumeric)
94
+ return 1;
95
+ return x > y ? 1 : -1;
96
+ }
97
+ return 0;
98
+ }
99
+ export function formatUpdateNotice(result) {
100
+ return [
101
+ `Update available ${result.current} → ${result.latest}`,
102
+ `Run \`npm install -g ${result.packageName}@latest\` to update.`,
103
+ 'Disable with MAW_NO_UPDATE_CHECK=1.',
104
+ ].join('\n');
105
+ }
106
+ /**
107
+ * True when the running CLI is a development install (`npm link` or a direct
108
+ * checkout). Overwriting those with a global install would silently orphan
109
+ * local code, so self-update refuses and the notice stays manual.
110
+ */
111
+ export function isDevelopmentInstall() {
112
+ if (process.env.MAW_DEV_INSTALL === '1' || process.env.CODER_DEV_INSTALL === '1')
113
+ return true;
114
+ try {
115
+ return !require.resolve('../package.json').split(sep).includes('node_modules');
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ function selfUpdateDisabled() {
122
+ return ['MAW_NO_SELF_UPDATE', 'CODER_NO_SELF_UPDATE'].some((name) => {
123
+ const value = process.env[name];
124
+ return value !== undefined && value !== '0' && value.toLowerCase() !== 'false';
125
+ });
126
+ }
127
+ function spawnNpm(command, args, timeoutMs, spawnImpl) {
128
+ return new Promise((resolveSpawn) => {
129
+ // shell on win32 lets Node resolve npm.cmd; on POSIX the args are fixed
130
+ // constants so no shell interpretation is possible.
131
+ const child = spawnImpl(command, args, {
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ shell: process.platform === 'win32',
134
+ });
135
+ let stdout = '';
136
+ let stderr = '';
137
+ let settled = false;
138
+ const finishSpawn = (code) => {
139
+ if (settled)
140
+ return;
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ resolveSpawn({ code, stdout, stderr });
144
+ };
145
+ const timer = setTimeout(() => {
146
+ child.kill('SIGKILL');
147
+ finishSpawn(null);
148
+ }, timeoutMs);
149
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
150
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
151
+ child.on('error', () => finishSpawn(null));
152
+ child.on('close', (code) => finishSpawn(code));
153
+ });
154
+ }
155
+ function lastLine(text) {
156
+ const lines = text.trim().split('\n');
157
+ return lines[lines.length - 1] ?? '';
158
+ }
159
+ /**
160
+ * Probes a registry before installing from it so a blocked mirror degrades to
161
+ * the default registry quickly instead of letting npm hang on timeouts.
162
+ */
163
+ async function registryReachable(packageName, registry, fetchImpl) {
164
+ try {
165
+ const response = await fetchImpl(`${registry}/${encodeURIComponent(packageName)}/latest`, {
166
+ headers: { accept: 'application/json' },
167
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
168
+ });
169
+ return response.ok;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ /**
176
+ * Installs the latest release globally via npm, preferring the China mirror
177
+ * (registry.npmmirror.com). Falls back to the default registry when the mirror
178
+ * is unreachable or its install fails. Never throws: failures are reported in
179
+ * the result so callers can keep the session running.
180
+ */
181
+ export async function selfUpdate(options = {}) {
182
+ const current = pkg.version ?? '0.0.0';
183
+ const target = options.version ?? 'latest';
184
+ const packageName = options.packageName ?? PKG_NAME;
185
+ const npmCommand = options.npmCommand ?? 'npm';
186
+ const spawnImpl = options.spawnImpl ?? spawn;
187
+ const fetchImpl = options.fetchImpl ?? fetch;
188
+ const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
189
+ const errors = [];
190
+ const fail = (message) => ({
191
+ ok: false, from: current, to: target, registry: '', errors: [...errors, message],
192
+ });
193
+ const devInstall = options.devInstall ?? isDevelopmentInstall();
194
+ if (devInstall) {
195
+ return fail('dev install detected (npm link); self-update skipped to avoid overwriting local code');
196
+ }
197
+ if (selfUpdateDisabled())
198
+ return fail('self-update disabled via MAW_NO_SELF_UPDATE');
199
+ const defaultRegistry = (options.registryUrl ?? REGISTRY_URL).replace(/\/$/, '');
200
+ const mirror = options.mirrorUrl === undefined ? NPM_MIRROR_REGISTRY : options.mirrorUrl;
201
+ const candidates = [mirror, defaultRegistry]
202
+ .filter((registry) => Boolean(registry))
203
+ .filter((registry, index, all) => all.indexOf(registry) === index);
204
+ for (const registry of candidates) {
205
+ if (!(await registryReachable(packageName, registry, fetchImpl))) {
206
+ errors.push(`${registry}: unreachable`);
207
+ continue;
208
+ }
209
+ const install = await spawnNpm(npmCommand, ['install', '-g', '--no-fund', '--no-audit', '--registry', registry, `${packageName}@${target}`], timeoutMs, spawnImpl);
210
+ if (install.code === 0) {
211
+ return { ok: true, from: current, to: target, registry, errors };
212
+ }
213
+ errors.push(`${registry}: npm exit ${install.code ?? 'killed'}${install.stderr.trim() ? ` — ${lastLine(install.stderr)}` : ''}`);
214
+ }
215
+ return { ok: false, from: current, to: target, registry: '', errors };
216
+ }
217
+ /**
218
+ * Asks `Update now? [y/N]` on the real stdin/stdout after the TUI has shut
219
+ * down. Defaults to no; only y/yes (any case) accepts. A 30s timeout or EOF
220
+ * also declines, and stdin that is already gone skips the prompt entirely.
221
+ */
222
+ export async function promptForUpdate(input = process.stdin, output = process.stdout, allowSelfUpdateOverride) {
223
+ if (selfUpdateDisabled())
224
+ return false;
225
+ if (!(allowSelfUpdateOverride ?? !isDevelopmentInstall()))
226
+ return false;
227
+ if (!input.readable)
228
+ return false;
229
+ // A TTY gets terminal mode so typed input is echoed; pipes (tests, CI) fall
230
+ // back to plain line reading.
231
+ const terminal = Boolean(input.isTTY);
232
+ const rl = createInterface({ input, output, terminal });
233
+ const answer = await new Promise((resolvePrompt) => {
234
+ const timeout = setTimeout(() => {
235
+ output.write('\n');
236
+ rl.close();
237
+ resolvePrompt('');
238
+ }, PROMPT_TIMEOUT_MS);
239
+ rl.question('Update now? [y/N] ', (text) => {
240
+ clearTimeout(timeout);
241
+ resolvePrompt(text);
242
+ });
243
+ rl.on('close', () => {
244
+ clearTimeout(timeout);
245
+ resolvePrompt('');
246
+ });
247
+ });
248
+ rl.close();
249
+ return /^\s*(y|yes)\s*$/i.test(answer);
250
+ }
251
+ /**
252
+ * End-to-end interactive flow: ask, and on confirmation self-update. Returns
253
+ * a printable message, or null when nothing happened. Failures leave the
254
+ * current install untouched and working.
255
+ */
256
+ export async function offerSelfUpdate(result, input = process.stdin, output = process.stdout, options = {}) {
257
+ if (!result.updateAvailable)
258
+ return null;
259
+ if (selfUpdateDisabled() || (options.devInstall ?? isDevelopmentInstall()))
260
+ return null;
261
+ const accepted = await promptForUpdate(input, output, options.devInstall === undefined ? undefined : !options.devInstall);
262
+ if (!accepted)
263
+ return null;
264
+ output.write(`Updating ${result.packageName} to ${result.latest} via npm (China mirror first)...\n`);
265
+ const outcome = await selfUpdate({ ...options, version: options.version ?? result.latest });
266
+ if (outcome.ok) {
267
+ return [
268
+ `Updated ${result.packageName} ${result.current} → ${result.latest} (from ${outcome.registry}).`,
269
+ 'Restart with `maw` to use the new version.',
270
+ ].join('\n');
271
+ }
272
+ return [
273
+ `Update to ${result.latest} failed:`,
274
+ ...outcome.errors.map((error) => ` - ${error}`),
275
+ `Run \`npm install -g ${result.packageName}@latest\` manually.`,
276
+ ].join('\n');
277
+ }
278
+ async function fetchLatestVersion(registryUrl, fetchImpl) {
279
+ const url = `${registryUrl}/${encodeURIComponent(PKG_NAME)}/latest`;
280
+ const response = await fetchImpl(url, {
281
+ headers: { accept: 'application/json' },
282
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
283
+ });
284
+ if (!response.ok)
285
+ return null;
286
+ const manifest = (await response.json());
287
+ return typeof manifest.version === 'string' ? manifest.version : null;
288
+ }
289
+ /**
290
+ * Checks npm for a newer release at most once per day (cached in
291
+ * CODER_DATA_HOME). Returns null when disabled, offline, up to date, or on
292
+ * any error — update checks must never break the CLI.
293
+ */
294
+ export async function checkForUpdate(options = {}) {
295
+ const current = pkg.version ?? '0.0.0';
296
+ if (updateCheckDisabled())
297
+ return null;
298
+ const path = options.cacheFile ?? cacheFilePath();
299
+ const registryUrl = (options.registryUrl ?? REGISTRY_URL).replace(/\/$/, '');
300
+ const fetchImpl = options.fetchImpl ?? fetch;
301
+ let cached = null;
302
+ try {
303
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
304
+ if (typeof parsed.checkedAt === 'number' && typeof parsed.latest === 'string')
305
+ cached = parsed;
306
+ }
307
+ catch {
308
+ cached = null;
309
+ }
310
+ let latest = null;
311
+ if (!options.force && cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
312
+ latest = cached.latest;
313
+ }
314
+ else {
315
+ latest = await fetchLatestVersion(registryUrl, fetchImpl).catch(() => null);
316
+ if (latest) {
317
+ const entry = { checkedAt: Date.now(), latest };
318
+ await mkdir(dirname(path), { recursive: true });
319
+ await atomicReplaceFile(path, `${JSON.stringify(entry, null, 2)}\n`).catch(async () => {
320
+ await writeFile(path, `${JSON.stringify(entry, null, 2)}\n`).catch(() => undefined);
321
+ });
322
+ }
323
+ }
324
+ if (!latest)
325
+ return null;
326
+ return {
327
+ packageName: PKG_NAME,
328
+ current,
329
+ latest,
330
+ updateAvailable: compareSemver(latest, current) > 0,
331
+ };
332
+ }
@@ -263,7 +263,7 @@ TUI 不再模拟任务管理器,而采用现代桌面聊天应用布局:
263
263
  - [ ] main 的“相关工作复用还是新建 coordinator”由 LLM/spec 决定,Runtime 尚未提供语义相似度或去重兜底。
264
264
  - [ ] TUI 已现代化但仍是 Blessed 单体界面,尚未拆成可复用组件;暂不提供 Web 客户端。
265
265
  - [ ] 多用户、多进程服务化和远程 agent 执行尚未实现。
266
- - [ ] Agent 级 token/cost 统计和完整 trace 导出尚未实现。
266
+ - [x] Agent 级 token/延迟统计已记录并在 TUI 状态栏展示;完整 trace 导出仍待后续补充。
267
267
 
268
268
  ## 10. 验收标准
269
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaw",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "clean": "node -e \"const fs=require('fs'),path=require('path'),p=path.resolve('dist');if(path.basename(p)!=='dist')throw new Error('unsafe clean target');fs.rmSync(p,{recursive:true,force:true})\"",
14
- "build": "npm run clean && tsc -p tsconfig.json",
14
+ "build": "npm run clean && tsc -p tsconfig.json && node -e \"const fs=require('fs');const p='dist/cli.js';if(fs.existsSync(p))fs.chmodSync(p,0o755)\"",
15
15
  "start": "node dist/cli.js",
16
16
  "dev": "tsx src/cli.ts",
17
17
  "test": "node --import tsx/esm --test tests/*.test.ts tests/**/*.test.ts",
@@ -43,5 +43,11 @@
43
43
  "coder": "dist/cli.js",
44
44
  "coding-agent": "dist/cli.js"
45
45
  },
46
- "description": "TUI-first, document-driven multi-agent coding runtime"
46
+ "description": "TUI-first, document-driven multi-agent coding runtime",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/elyar-adil/coder.git"
50
+ },
51
+ "homepage": "https://github.com/elyar-adil/coder#readme",
52
+ "bugs": "https://github.com/elyar-adil/coder/issues"
47
53
  }