zames_pro 2.61.0 → 2.63.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,86 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.63.1] - 2026-10-06
9
+
10
+ ### Fixed
11
+
12
+ - Мигание таймера в статусе паузы перед отправкой: `sendPause()` (и
13
+ `LineEditor`, и non-TTY `SpinnerUI`) перерисовывал статус БЕЗ хвоста
14
+ «elapsed»/бейджа задач, а тик анимации рисовал его С ним — раз в секунду
15
+ подпись `· 1m 50s` исчезала и появлялась. Теперь обновление собирает тот же
16
+ хвост, что и тик. Регрессионный тест в test/compact-statusline.test.ts.
17
+
18
+ ### Changed
19
+
20
+ - README: центрированная шапка, логотип крупнее (640px-ассет, 430px показ),
21
+ диаграмма «How it works», оглавление и секция Links.
22
+ - Dependabot: мажорные обновления игнорируются (`semver-major`), расписание
23
+ раз в месяц, авто-мерж только для patch/minor и только для PR бота
24
+ (проверка автора и ветки); workflow авто-мержа. Ветка `master` защищена
25
+ ruleset: PR обязателен, required-check — стабильный job `ci`.
26
+
27
+ ## [2.63.0] - 2026-10-06
28
+
29
+ ### Added
30
+
31
+ - Реальные подтверждения действий (BACKLOG C1): политика approval в
32
+ `.zames/permissions.json` (`default` + `rules` с regexp по `tool`/`command`/
33
+ `path` и действием `allow`|`deny`|`ask`). Вызывается в `agent-loop.ts` ПЕРЕД
34
+ каждым инструментом: `deny` блокирует вызов, `ask` спрашивает оператора через
35
+ `onAskPermission` (в TTY — интерактивный промпт над инпут-линией, в non-TTY —
36
+ запрет). Новый чистый модуль `src/permissions.ts` + тесты
37
+ (test/permissions.test.ts, test/permissions-loop.test.ts).
38
+
39
+ - Кастомные команды: аргументы и подсказки (BACKLOG B7). Frontmatter
40
+ `argument-hint:` показывается в списке «/» и в `/help` (не вставляется в
41
+ строку ввода), а `arguments:` объявляет обязательные позиционные аргументы.
42
+ В теле команды подставляются `$1 $2`, именованные `$name`, а также прежние
43
+ `{{args}}`/`$ARGUMENTS`. Если обязательные аргументы не переданы — команда
44
+ не уходит в чат, печатается подсказка. Новые чистые хелперы
45
+ `splitCommandArgs()` / `expandCommandArgs()` / `missingCommandArgs()`
46
+ (src/commands.ts, покрыты тестами).
47
+
48
+ - Path-scoped правила (BACKLOG B6): вложенные `AGENTS.md`/`MEMORY.md` из
49
+ подпапок, которых касается задача, подтягиваются автоматически. Текст задачи
50
+ сканируется на path-токены, для найденных директорий (и их предков ниже
51
+ рабочей) читаются ближайшие инструкции и рендерятся отдельной секцией
52
+ `## Scoped instructions (...)` — явно помечены как действующие только для
53
+ этих файлов. `loadProjectContext(workdir, touchPaths?)` и
54
+ `renderContextSection()` (src/context.ts, src/system-prompt.ts; покрыто
55
+ тестами).
56
+
57
+ - `@file`-ссылки в задаче (BACKLOG B5): `реши задачу @src/browser.ts`
58
+ подставляет содержимое указанного файла прямо в задачу, экономя отдельный
59
+ ход агента на чтение. Распознаётся `@path` на границе слова (в начале строки
60
+ или после пробела/скобки/кавычки), с расширением файла; `user@host` и
61
+ декораторы (`@Component`) не трогаются. Существующие файлы инлайнятся
62
+ (лимит 60 КБ на файл, 200 КБ суммарно — сверх этого усечение с пометкой),
63
+ несуществующие остаются как есть. Хелпер `extractAtFileRefs()` в
64
+ `src/path-token.ts` (чистый, покрыт тестами).
65
+
66
+ ### Changed
67
+
68
+ - `AGENTS.md` уменьшен (BACKLOG C2): глубокие root-cause разборы («агент
69
+ остановился», чтение ответа из DOM, send-хуки, `LineEditor`, вложения) и
70
+ терминальная механика вынесены в `docs/DESIGN-NOTES.md` (progressive
71
+ disclosure — не грузится в каждую задачу). В AGENTS.md остались действующие
72
+ правила и краткая выжимка со ссылкой.
73
+
74
+ ## [2.62.0]
75
+
76
+ ### Added
77
+
78
+ - Checkpoints / rewind (BACKLOG B3): at the start of every task the working
79
+ tree is snapshotted into a tarball under `~/.zames/checkpoints/`, and
80
+ `/rewind [n]` rolls it back in one step (with a confirmation prompt and an
81
+ automatic `pre-rewind` backup of the current state, so the rewind itself is
82
+ reversible). `/rewind-list` shows the recent checkpoints. `node_modules`,
83
+ `.git`, `dist` and `tmp` are never snapshotted or deleted. Unlike a manual
84
+ git stash, this works in a non-git directory and never touches the
85
+ operator's index. New module `src/checkpoint.ts` (pure helpers + `CheckpointStore`,
86
+ unit-tested); configurable via `checkpoint.enabled` / `checkpoint.maxBackups`
87
+ (default 50) in `/config`.
9
88
 
10
89
  ## [2.61.0]
11
90
 
@@ -249,7 +328,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
249
328
  - Session banner warns when no saved DeepSeek session exists.
250
329
  - `--no-color` flag (explicit `NO_COLOR`).
251
330
 
252
- [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.61.0...HEAD
331
+ [Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.63.1...HEAD
332
+ [2.63.1]: https://github.com/Viqto0r/zames_pro/compare/v2.63.0...v2.63.1
333
+ [2.63.0]: https://github.com/Viqto0r/zames_pro/compare/v2.61.0...v2.63.0
253
334
  [2.61.0]: https://github.com/Viqto0r/zames_pro/compare/v2.60.0...v2.61.0
254
335
  [2.60.0]: https://github.com/Viqto0r/zames_pro/compare/v2.59.0...v2.60.0
255
336
  [2.59.0]: https://github.com/Viqto0r/zames_pro/compare/v2.58.0...v2.59.0
package/README.md CHANGED
@@ -1,12 +1,20 @@
1
- # zames_pro
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/logo-small.jpg" alt="zames logo" width="430">
3
+ </p>
2
4
 
3
- [![npm version](https://img.shields.io/npm/v/zames_pro.svg)](https://www.npmjs.com/package/zames_pro)
4
- [![npm downloads](https://img.shields.io/npm/dm/zames_pro.svg)](https://www.npmjs.com/package/zames_pro)
5
- [![tests](https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml/badge.svg)](https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
- [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](package.json)
5
+ <h1 align="center">zames_pro</h1>
8
6
 
9
- ![zames logo](https://raw.githubusercontent.com/Viqto0r/zames_pro/master/logo.jpg)
7
+ <p align="center">
8
+ <strong>A terminal coding agent that drives chat.deepseek.com through Playwright — no API key required.</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/v/zames_pro.svg" alt="npm version"></a>
13
+ <a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/dm/zames_pro.svg" alt="npm downloads"></a>
14
+ <a href="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml"><img src="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml/badge.svg" alt="tests"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
16
+ <a href="package.json"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="Node.js"></a>
17
+ </p>
10
18
 
11
19
  A terminal coding agent that works on top of [chat.deepseek.com](https://chat.deepseek.com/) through Playwright.
12
20
  In spirit it is similar to Claude Code / Codex CLI: it starts in the current
@@ -15,6 +23,44 @@ directory, reads and edits files, runs commands, and commits to git.
15
23
  > No API key required — it drives the DeepSeek web chat like a regular user
16
24
  > through a real (headless) browser.
17
25
 
26
+ ## How it works
27
+
28
+ zames does not call the model API. It launches a headless Chromium with a
29
+ persistent profile, signs in to `chat.deepseek.com` like a human, types the task
30
+ into the chat box, and reads the answer back.
31
+
32
+ ```
33
+ terminal (you) zames chat.deepseek.com
34
+ ─────────────── ───── ─────────────────
35
+ task ──────────────────────▶ system prompt + task ────────▶ browser types it
36
+ ▲ │
37
+ │ model answers
38
+ │ │
39
+ answer ◀──── render ◀───── parse tool-call ◀──── raw answer ◀────┘
40
+ │
41
+ ▼
42
+ run tool (Read/Edit/Bash/…) → feed result back
43
+ ```
44
+
45
+ Key pieces:
46
+
47
+ - **The answer is read from the raw network stream** (SSE), not the rendered
48
+ DOM, so tool-call JSON with template strings and escapes survives intact.
49
+ - **A persistent profile** (`~/.zames/profile`) keeps you signed in; the
50
+ headless User-Agent is patched so DeepSeek's CDN does not 403 the login.
51
+ - **A send throttle** (15 s by default) keeps the web chat's rate limit happy
52
+ during long tool-heavy runs.
53
+ - **The agent is sandboxed** to the directory it was started in — no tool can
54
+ read or write above it.
55
+
56
+ ## Table of contents
57
+
58
+ - [Features](#features) · [Why zames?](#why-zames) · [Requirements](#requirements)
59
+ - [How it works](#how-it-works) · [Installation](#installation) · [Signing in](#signing-in) · [Usage](#usage)
60
+ - [Tools](#tools) · [Slash commands](#slash-commands)
61
+ - [Project context, skills and memory](#project-context-skills-and-memory) · [MCP (external tools)](#mcp-external-tools) · [Configuration](#configuration)
62
+ - [FAQ](#faq) · [Links](#links) · [License](#license)
63
+
18
64
  ## Features
19
65
 
20
66
  - **Tools like Claude Code / Codex** — `Read`, `Write`, `Edit`, `Bash`,
@@ -82,12 +128,6 @@ version). You do not need `--headed` just to log in.
82
128
  Credentials and toggles can also be edited from `/config`
83
129
  (`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
84
130
 
85
- ## Links
86
-
87
- - npm: <https://www.npmjs.com/package/zames_pro>
88
- - Changelog: [`CHANGELOG.md`](CHANGELOG.md)
89
- - Security policy: [`SECURITY.md`](SECURITY.md)
90
-
91
131
  ## Installation
92
132
 
93
133
  ```bash
@@ -406,6 +446,15 @@ Same shape (tools, `AGENTS.md`, skills, MCP, slash commands) but it runs on your
406
446
  DeepSeek account instead of an API, as a browser automation rather than a
407
447
  first-party API client.
408
448
 
449
+ ## Links
450
+
451
+ - **npm:** <https://www.npmjs.com/package/zames_pro>
452
+ - **GitHub:** <https://github.com/Viqto0r/zames_pro>
453
+ - **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
454
+ - **Contributing:** [`CONTRIBUTING.md`](CONTRIBUTING.md)
455
+ - **Security policy:** [`SECURITY.md`](SECURITY.md)
456
+ - **Code of conduct:** [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
457
+
409
458
  ## License
410
459
 
411
460
  MIT
@@ -6,12 +6,15 @@ import { translate } from './i18n.js';
6
6
  import { substituteAttachmentMarkers } from './commands.js';
7
7
  import { normText } from './browser.js';
8
8
  import { loadHooks, runPreToolUse, runPostToolUse, } from './hooks.js';
9
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, selfImprovement = false, }) {
9
+ import { loadPermissions, decidePermission, } from './permissions.js';
10
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, onAskPermission = undefined, selfImprovement = false, }) {
10
11
  // Resolve the hook config ONCE per task: a read per tool call would be
11
12
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
12
13
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
13
14
  // hooks entirely.
14
15
  const hookConfig = hooks === undefined ? loadHooks(workdir) : (hooks ?? {});
16
+ // Same for the approval policy (C1): read `.zames/permissions.json` once.
17
+ const permissionPolicy = permissions === undefined ? loadPermissions(workdir) : permissions;
15
18
  // UI callbacks must NEVER break the agent loop. A rendering error (a huge
16
19
  // tool result, a broken markdown frame, a closed terminal) used to throw
17
20
  // out of the loop right after a tool call — the session looked "stopped
@@ -91,7 +94,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
91
94
  }
92
95
  let context;
93
96
  try {
94
- context = await loadProjectContext(workdir);
97
+ // Pass the task text so nested AGENTS.md/MEMORY.md for the directories
98
+ // this task actually touches are pulled in (B6, path-scoped rules).
99
+ context = await loadProjectContext(workdir, [task]);
95
100
  }
96
101
  catch {
97
102
  context = null;
@@ -108,6 +113,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
108
113
  length: systemPrompt.length,
109
114
  gitContext: gitText,
110
115
  agents: context?.agents.map((f) => f.path) ?? [],
116
+ scopedAgents: context?.scopedAgents?.map((f) => f.path) ?? [],
111
117
  memory: context?.memory.map((f) => f.path) ?? [],
112
118
  skills: context?.skills.map((s) => s.name) ?? [],
113
119
  commands: context?.commands.map((c) => c.name) ?? [],
@@ -727,6 +733,47 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
727
733
  results.push({ tool: call.tool, result: blocked });
728
734
  continue;
729
735
  }
736
+ // Approval policy (C1): a `deny` rule blocks the call (like a PreToolUse
737
+ // denial); an `ask` rule prompts the operator. Hooks stay authoritative
738
+ // for programmatic guards — this is the human-in-the-loop layer.
739
+ const decision = decidePermission(permissionPolicy, call.tool, call.args);
740
+ if (decision.action === 'deny') {
741
+ const blocked = `Blocked by permission policy: ${decision.reason}`;
742
+ transcript?.log('permission_deny', {
743
+ tool: call.tool,
744
+ reason: decision.reason,
745
+ });
746
+ safeToolResult(blocked);
747
+ transcript?.log('tool_result', {
748
+ tool: call.tool,
749
+ result: blocked,
750
+ });
751
+ results.push({ tool: call.tool, result: blocked });
752
+ continue;
753
+ }
754
+ if (decision.action === 'ask' && onAskPermission) {
755
+ let allowed;
756
+ try {
757
+ allowed = await onAskPermission({ ...decision, tool: call.tool });
758
+ }
759
+ catch {
760
+ allowed = false;
761
+ }
762
+ if (!allowed) {
763
+ const blocked = `Denied by operator: ${decision.reason}`;
764
+ transcript?.log('permission_denied', {
765
+ tool: call.tool,
766
+ reason: decision.reason,
767
+ });
768
+ safeToolResult(blocked);
769
+ transcript?.log('tool_result', {
770
+ tool: call.tool,
771
+ result: blocked,
772
+ });
773
+ results.push({ tool: call.tool, result: blocked });
774
+ continue;
775
+ }
776
+ }
730
777
  let result;
731
778
  // While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
732
779
  // boolean set by stopGeneration(), so the only way to turn it into a
@@ -735,6 +782,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
735
782
  const poll = setInterval(syncToolAbort, 100);
736
783
  if (typeof poll.unref === 'function')
737
784
  poll.unref();
785
+ // Time the tool (T-D3): the transcript carries durationMs so /cost can
786
+ // show where the time goes (frequent Read→Edit cycles vs slow Bash).
787
+ const toolStart = Date.now();
738
788
  try {
739
789
  result = await tool.fn(call.args, { signal: toolAbort.signal });
740
790
  }
@@ -744,6 +794,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
744
794
  finally {
745
795
  clearInterval(poll);
746
796
  }
797
+ const toolMs = Date.now() - toolStart;
747
798
  // PostToolUse hooks run AFTER the tool; their stdout is appended to the
748
799
  // result (e.g. `prettier` output) before it is fed back to the model.
749
800
  // Best-effort: a hook failure is ignored, the tool result still stands.
@@ -759,6 +810,7 @@ ${post}`;
759
810
  transcript?.log('tool_result', {
760
811
  tool: call.tool,
761
812
  result: String(result),
813
+ durationMs: toolMs,
762
814
  });
763
815
  results.push({ tool: call.tool, result });
764
816
  // The operator pressed Esc/Ctrl+C while the tool was running. The tool
@@ -0,0 +1,197 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFile } from 'child_process';
5
+ import { writeJsonAtomic } from './fsutil.js';
6
+ // Checkpoints / rewind (BACKLOG B3).
7
+ //
8
+ // Undo (src/undo.ts) reverts ONE file write. A checkpoint snapshots the WHOLE
9
+ // working tree at the start of a task, so a risky task can be rolled back with
10
+ // a single `/rewind`. A tarball — not a git stash — is used on purpose: it
11
+ // works in a non-git directory and never touches the operator's index/stash.
12
+ //
13
+ // Machine codes only ('not_found', 'archive_missing', ...): the caller
14
+ // (src/index.ts) localizes them through the i18n catalog, exactly like
15
+ // undo.ts. Keeping prose out of this module is also why no Russian leaks here
16
+ // (see test/agent-facing-english.test.ts).
17
+ const CHECKPOINT_DIR = path.join(os.homedir(), '.zames', 'checkpoints');
18
+ // Never snapshotted and never deleted on restore: dependencies, the git object
19
+ // store (the operator's own safety net), build output and the agent's scratch
20
+ // dir. Everything else is part of the snapshot. Patterns are anchored to the
21
+ // archive root ('./x') so a nested `src/tmp` is NOT accidentally excluded.
22
+ export const CHECKPOINT_EXCLUDES = ['node_modules', '.git', 'dist', 'tmp'];
23
+ // ---------- pure helpers (unit-tested without tar) ----------
24
+ export function tarCreateArgs(archive, _dir) {
25
+ const args = ['-czf', archive];
26
+ for (const e of CHECKPOINT_EXCLUDES)
27
+ args.push('--exclude=./' + e);
28
+ args.push('-C', _dir, '.');
29
+ return args;
30
+ }
31
+ export function tarExtractArgs(archive, dir) {
32
+ return ['-xzf', archive, '-C', dir];
33
+ }
34
+ /** Keep only the newest `max` records (returns a new array; order preserved). */
35
+ export function rotateRecords(records, max) {
36
+ if (max <= 0)
37
+ return [];
38
+ if (records.length <= max)
39
+ return records.slice();
40
+ return records.slice(records.length - max);
41
+ }
42
+ /**
43
+ * Resolve a `/rewind` selector against records sorted NEWEST FIRST.
44
+ * `1` = most recent; a non-numeric selector is matched as a stamp prefix.
45
+ */
46
+ export function pickRecord(records, selector) {
47
+ const sel = String(selector ?? '').trim();
48
+ if (!sel)
49
+ return null;
50
+ if (/^\d+$/.test(sel)) {
51
+ const n = Number(sel);
52
+ // A small number is an index (1 = newest). An out-of-range number is
53
+ // still tried as a stamp prefix below, so `/rewind 1730000` works.
54
+ if (n >= 1 && n <= records.length)
55
+ return records[n - 1];
56
+ }
57
+ return records.find((r) => String(r.stamp).startsWith(sel)) || null;
58
+ }
59
+ // ---------- tar / fs plumbing ----------
60
+ function runTar(args, cwd, timeout = 60_000) {
61
+ return new Promise((resolve) => {
62
+ execFile('tar', args, { cwd, timeout, maxBuffer: 1024 * 1024 * 8, windowsHide: true }, (err, _stdout, stderr) => {
63
+ if (!err)
64
+ return resolve({ code: 0, stderr: '' });
65
+ const raw = err.code;
66
+ const code = typeof raw === 'number' ? raw : 127;
67
+ resolve({ code, stderr: String(stderr || err.message || '') });
68
+ });
69
+ });
70
+ }
71
+ async function copyTree(from, to) {
72
+ await fs.mkdir(to, { recursive: true });
73
+ const entries = await fs.readdir(from, { withFileTypes: true });
74
+ for (const e of entries) {
75
+ const src = path.join(from, e.name);
76
+ const dst = path.join(to, e.name);
77
+ if (e.isDirectory())
78
+ await copyTree(src, dst);
79
+ else if (e.isSymbolicLink()) {
80
+ const link = await fs.readlink(src).catch(() => null);
81
+ if (link !== null)
82
+ await fs.symlink(link, dst).catch(() => { });
83
+ }
84
+ else
85
+ await fs.copyFile(src, dst);
86
+ }
87
+ }
88
+ // ---------- store ----------
89
+ export class CheckpointStore {
90
+ enabled;
91
+ maxBackups;
92
+ dir;
93
+ constructor({ enabled = true, maxBackups = 50, dir = CHECKPOINT_DIR, } = {}) {
94
+ this.enabled = enabled;
95
+ this.maxBackups = maxBackups;
96
+ this.dir = dir;
97
+ }
98
+ get indexFile() {
99
+ return path.join(this.dir, 'index.json');
100
+ }
101
+ async _ensure() {
102
+ await fs.mkdir(this.dir, { recursive: true });
103
+ }
104
+ async _readIndex() {
105
+ try {
106
+ const data = JSON.parse(await fs.readFile(this.indexFile, 'utf-8'));
107
+ return Array.isArray(data) ? data : [];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * Snapshot `workdir` into a tarball. Best-effort: a missing tar, an
115
+ * unreadable dir or a timeout yields null, never a thrown error — a failed
116
+ * checkpoint must not abort the task it was meant to protect.
117
+ */
118
+ async create(workdir, label) {
119
+ if (!this.enabled)
120
+ return null;
121
+ await this._ensure();
122
+ const stamp = Date.now();
123
+ const file = path.join(this.dir, `${stamp}.tgz`);
124
+ const res = await runTar(tarCreateArgs(file, workdir), workdir);
125
+ if (res.code !== 0) {
126
+ await fs.rm(file, { force: true }).catch(() => { });
127
+ return null;
128
+ }
129
+ const record = { stamp, file, workdir };
130
+ if (label)
131
+ record.label = label;
132
+ const history = await this._readIndex();
133
+ history.push(record);
134
+ const kept = rotateRecords(history, this.maxBackups);
135
+ // Drop the archives that fell out of the retention window.
136
+ const keptStamps = new Set(kept.map((r) => r.stamp));
137
+ for (const r of history) {
138
+ if (!keptStamps.has(r.stamp))
139
+ await fs.rm(r.file, { force: true }).catch(() => { });
140
+ }
141
+ writeJsonAtomic(this.indexFile, kept);
142
+ return record;
143
+ }
144
+ /** Newest-first list, capped at `count`. */
145
+ async list(count = 10) {
146
+ const history = await this._readIndex();
147
+ return history.slice().reverse().slice(0, count);
148
+ }
149
+ /**
150
+ * Restore the working tree to a checkpoint. Backs up the CURRENT tree first
151
+ * (a 'pre-rewind' checkpoint), so the rewind itself is reversible. Returns
152
+ * machine codes; the caller localizes them.
153
+ */
154
+ async restore(selector) {
155
+ if (!this.enabled)
156
+ return { ok: false, reason: 'disabled' };
157
+ const history = await this._readIndex();
158
+ const newestFirst = history.slice().reverse();
159
+ const record = pickRecord(newestFirst, selector);
160
+ if (!record)
161
+ return { ok: false, reason: 'not_found' };
162
+ const exists = await fs
163
+ .stat(record.file)
164
+ .then(() => true)
165
+ .catch(() => false);
166
+ if (!exists)
167
+ return { ok: false, reason: 'archive_missing' };
168
+ // Backup the CURRENT tree before touching it.
169
+ const backup = await this.create(record.workdir, 'pre-rewind');
170
+ // Extract to a temp dir FIRST; only a successful extract may touch workdir
171
+ // (otherwise a bad archive would wipe the tree).
172
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-rewind-'));
173
+ try {
174
+ const res = await runTar(tarExtractArgs(record.file, tmp), tmp);
175
+ if (res.code !== 0)
176
+ return { ok: false, reason: 'extract_failed' };
177
+ // Full restore: drop everything in workdir except the excludes, then
178
+ // copy the snapshot in. A partial extract would leave behind files the
179
+ // task created after the checkpoint.
180
+ const entries = await fs
181
+ .readdir(record.workdir)
182
+ .catch(() => []);
183
+ for (const e of entries) {
184
+ if (CHECKPOINT_EXCLUDES.includes(e))
185
+ continue;
186
+ await fs
187
+ .rm(path.join(record.workdir, e), { recursive: true, force: true })
188
+ .catch(() => { });
189
+ }
190
+ await copyTree(tmp, record.workdir);
191
+ return { ok: true, record, backup };
192
+ }
193
+ finally {
194
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
195
+ }
196
+ }
197
+ }
package/dist/commands.js CHANGED
@@ -82,6 +82,8 @@ export function summarizeTranscript(entries) {
82
82
  turns: 0,
83
83
  toolCalls: 0,
84
84
  toolCounts: {},
85
+ toolMs: 0,
86
+ toolTime: {},
85
87
  durationMs: 0,
86
88
  startedAt: null,
87
89
  autoCompacts: 0,
@@ -98,6 +100,14 @@ export function summarizeTranscript(entries) {
98
100
  const tool = String(e.tool ?? '?');
99
101
  stats.toolCounts[tool] = (stats.toolCounts[tool] || 0) + 1;
100
102
  }
103
+ if (e.type === 'tool_result') {
104
+ const ms = Number(e.durationMs);
105
+ if (Number.isFinite(ms) && ms > 0) {
106
+ stats.toolMs += ms;
107
+ const tool = String(e.tool ?? '?');
108
+ stats.toolTime[tool] = (stats.toolTime[tool] || 0) + ms;
109
+ }
110
+ }
101
111
  if (typeof e.elapsed === 'number' && e.elapsed > stats.durationMs) {
102
112
  stats.durationMs = e.elapsed;
103
113
  }
@@ -179,6 +189,19 @@ export function renderCost(stats, transcriptFile, tokenUsage = null, t = transla
179
189
  for (const [name, n] of top)
180
190
  lines.push(' ' + name + ': ' + n);
181
191
  }
192
+ // T-D3: where the time went. Sort by total ms, show the top tools with the
193
+ // count and a human duration, so Read→Edit cycles and slow Bash calls are
194
+ // visible at a glance.
195
+ const byTime = Object.entries(stats.toolTime || {})
196
+ .filter(([, ms]) => ms > 0)
197
+ .sort((a, b) => b[1] - a[1]);
198
+ if (byTime.length) {
199
+ lines.push(t('cost.by_time', { dur: formatDuration(stats.toolMs) }));
200
+ for (const [name, ms] of byTime.slice(0, 8)) {
201
+ const count = stats.toolCounts[name] ?? 0;
202
+ lines.push(' ' + name + ': ' + formatDuration(ms) + ' ×' + count);
203
+ }
204
+ }
182
205
  lines.push(t('cost.duration', { dur: formatDuration(stats.durationMs) }));
183
206
  if (stats.startedAt)
184
207
  lines.push(t('cost.started', { v: stats.startedAt }));
@@ -615,6 +638,118 @@ export function rewriteFailedAttachments(text, failed) {
615
638
  }
616
639
  return out;
617
640
  }
641
+ // Split a custom-command argument string into positional tokens, honoring
642
+ // single/double quotes (so two quoted words stay one token). Pure, so it is
643
+ // unit-tested.
644
+ export function splitCommandArgs(rest) {
645
+ const out = [];
646
+ const s = String(rest ?? '');
647
+ const SQ = String.fromCharCode(39);
648
+ const DQ = String.fromCharCode(34);
649
+ let cur = '';
650
+ let quote = '';
651
+ for (let i = 0; i < s.length; i++) {
652
+ const ch = s[i];
653
+ if (quote) {
654
+ if (ch === quote) {
655
+ quote = '';
656
+ continue;
657
+ }
658
+ cur += ch;
659
+ continue;
660
+ }
661
+ if (ch === SQ || ch === DQ) {
662
+ quote = ch;
663
+ continue;
664
+ }
665
+ if (ch.charCodeAt(0) <= 32) {
666
+ if (cur) {
667
+ out.push(cur);
668
+ cur = '';
669
+ }
670
+ continue;
671
+ }
672
+ cur += ch;
673
+ }
674
+ if (cur)
675
+ out.push(cur);
676
+ return out;
677
+ }
678
+ function isAllDigits(tok) {
679
+ if (!tok)
680
+ return false;
681
+ for (let i = 0; i < tok.length; i++) {
682
+ const c = tok.charCodeAt(i);
683
+ if (c < 48 || c > 57)
684
+ return false;
685
+ }
686
+ return true;
687
+ }
688
+ function isWordChar(ch) {
689
+ const c = ch.charCodeAt(0);
690
+ return ((c >= 48 && c <= 57) ||
691
+ (c >= 65 && c <= 90) ||
692
+ (c >= 97 && c <= 122) ||
693
+ ch === '_');
694
+ }
695
+ // Replace $1..$N and $name tokens in an already-expanded body. A whole word is
696
+ // read after $ so $foobar never matches the name foo. Pure.
697
+ function replaceDollarTokens(s, parts, names) {
698
+ let res = '';
699
+ let i = 0;
700
+ while (i < s.length) {
701
+ const ch = s[i];
702
+ if (ch !== '$') {
703
+ res += ch;
704
+ i++;
705
+ continue;
706
+ }
707
+ let j = i + 1;
708
+ let tok = '';
709
+ while (j < s.length && isWordChar(s[j])) {
710
+ tok += s[j];
711
+ j++;
712
+ }
713
+ if (!tok) {
714
+ res += ch;
715
+ i++;
716
+ continue;
717
+ }
718
+ if (isAllDigits(tok)) {
719
+ res += parts[Number(tok) - 1] ?? '';
720
+ }
721
+ else {
722
+ const idx = names.indexOf(tok);
723
+ res += idx >= 0 ? (parts[idx] ?? '') : '$' + tok;
724
+ }
725
+ i = j;
726
+ }
727
+ return res;
728
+ }
729
+ // Expand a custom-command body: {{args}} / $ARGUMENTS become the whole argument
730
+ // string, $1..$N the positional tokens, $name the declared positions. Pure, so
731
+ // it is unit-tested.
732
+ export function expandCommandArgs(body, rest, names = []) {
733
+ const parts = splitCommandArgs(rest);
734
+ let out = String(body ?? '');
735
+ out = out.split('{{args}}').join(rest);
736
+ out = out.split('$ARGUMENTS').join(rest);
737
+ out = replaceDollarTokens(out, parts, names);
738
+ return out.trim();
739
+ }
740
+ // Names declared in `arguments:` but not supplied on the command line (by
741
+ // position). Pure, so the caller can refuse an incomplete invocation.
742
+ export function missingCommandArgs(names, rest) {
743
+ if (!names || !names.length)
744
+ return [];
745
+ const parts = splitCommandArgs(rest);
746
+ const missing = [];
747
+ for (let i = 0; i < names.length; i++) {
748
+ if (!parts[i])
749
+ missing.push(names[i]);
750
+ }
751
+ return missing;
752
+ }
618
753
  // True when a `/config ...` line can be handled LIVE (while the agent is busy):
619
754
  // the text subcommands only touch config values, never the chat. The bare
620
755
  // `/config` and `/config menu` (which pause the editor and read keys) are NOT