@visorcraft/idlehands 1.3.3 → 1.3.5

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.
Files changed (70) hide show
  1. package/README.md +28 -1
  2. package/dist/agent/formatting.js +1 -5
  3. package/dist/agent/formatting.js.map +1 -1
  4. package/dist/agent.js +130 -22
  5. package/dist/agent.js.map +1 -1
  6. package/dist/anton/controller.js +20 -1
  7. package/dist/anton/controller.js.map +1 -1
  8. package/dist/anton/reporter.js +2 -20
  9. package/dist/anton/reporter.js.map +1 -1
  10. package/dist/bot/auto-continue.js +24 -0
  11. package/dist/bot/auto-continue.js.map +1 -0
  12. package/dist/bot/commands.js +50 -0
  13. package/dist/bot/commands.js.map +1 -1
  14. package/dist/bot/discord-commands.js +833 -0
  15. package/dist/bot/discord-commands.js.map +1 -0
  16. package/dist/bot/discord-routing.js +1 -8
  17. package/dist/bot/discord-routing.js.map +1 -1
  18. package/dist/bot/discord.js +36 -789
  19. package/dist/bot/discord.js.map +1 -1
  20. package/dist/bot/session-manager.js +52 -0
  21. package/dist/bot/session-manager.js.map +1 -1
  22. package/dist/bot/telegram-commands.js +201 -0
  23. package/dist/bot/telegram-commands.js.map +1 -0
  24. package/dist/bot/telegram.js +32 -310
  25. package/dist/bot/telegram.js.map +1 -1
  26. package/dist/bot/ux/events.js +142 -0
  27. package/dist/bot/ux/events.js.map +1 -0
  28. package/dist/cli/commands/project.js +52 -0
  29. package/dist/cli/commands/project.js.map +1 -1
  30. package/dist/config.js +16 -0
  31. package/dist/config.js.map +1 -1
  32. package/dist/context.js +1 -3
  33. package/dist/context.js.map +1 -1
  34. package/dist/progress/ir.js +0 -3
  35. package/dist/progress/ir.js.map +1 -1
  36. package/dist/progress/tool-summary.js +1 -4
  37. package/dist/progress/tool-summary.js.map +1 -1
  38. package/dist/progress/turn-progress.js +1 -5
  39. package/dist/progress/turn-progress.js.map +1 -1
  40. package/dist/runtime/executor.js +1 -3
  41. package/dist/runtime/executor.js.map +1 -1
  42. package/dist/runtime/health.js +2 -1
  43. package/dist/runtime/health.js.map +1 -1
  44. package/dist/shared/async.js +5 -0
  45. package/dist/shared/async.js.map +1 -0
  46. package/dist/shared/config-utils.js +8 -0
  47. package/dist/shared/config-utils.js.map +1 -0
  48. package/dist/shared/format.js +19 -0
  49. package/dist/shared/format.js.map +1 -0
  50. package/dist/shared/math.js +5 -0
  51. package/dist/shared/math.js.map +1 -0
  52. package/dist/shared/strings.js +8 -0
  53. package/dist/shared/strings.js.map +1 -0
  54. package/dist/tools/patch.js +82 -0
  55. package/dist/tools/patch.js.map +1 -0
  56. package/dist/tools/path-safety.js +89 -0
  57. package/dist/tools/path-safety.js.map +1 -0
  58. package/dist/tools/undo.js +141 -0
  59. package/dist/tools/undo.js.map +1 -0
  60. package/dist/tools.js +11 -289
  61. package/dist/tools.js.map +1 -1
  62. package/dist/tui/controller.js +24 -1
  63. package/dist/tui/controller.js.map +1 -1
  64. package/dist/tui/event-bridge.js +1 -3
  65. package/dist/tui/event-bridge.js.map +1 -1
  66. package/dist/tui/render.js +1 -5
  67. package/dist/tui/render.js.map +1 -1
  68. package/dist/vault.js +1 -5
  69. package/dist/vault.js.map +1 -1
  70. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import crypto from 'node:crypto';
4
3
  import { spawn, spawnSync } from 'node:child_process';
5
4
  import { ToolError } from './tools/tool-error.js';
6
5
  import { checkExecSafety, checkPathSafety, isProtectedDeleteTarget } from './safety.js';
7
6
  import { sys_context as sysContextTool } from './sys/context.js';
8
- import { stateDir, shellEscape, BASH_PATH } from './utils.js';
9
- const DEFAULT_MAX_BACKUPS_PER_FILE = 5;
7
+ import { isWithinDir, resolvePath, redactPath, checkCwdWarning, enforceMutationWithinCwd, } from './tools/path-safety.js';
8
+ import { normalizePatchPath, extractTouchedFilesFromPatch, } from './tools/patch.js';
9
+ import { atomicWrite, backupFile } from './tools/undo.js';
10
+ import { shellEscape, BASH_PATH } from './utils.js';
11
+ // Re-export from extracted modules so existing imports don't break
12
+ export { atomicWrite, undo_path } from './tools/undo.js';
13
+ // Backup/undo system imported from tools/undo.ts (atomicWrite, backupFile, undo_path)
10
14
  /**
11
15
  * Build a read-back snippet showing the region around a mutation.
12
16
  * Returns numbered lines ±contextLines around the changed area, capped to avoid bloat.
@@ -73,61 +77,6 @@ function guessMimeType(filePath, buf) {
73
77
  };
74
78
  return extMap[ext] ?? 'application/octet-stream';
75
79
  }
76
- function defaultBackupDir() {
77
- return path.join(stateDir(), 'backups');
78
- }
79
- function sha256(s) {
80
- return crypto.createHash('sha256').update(s).digest('hex');
81
- }
82
- function keyFromPath(absPath) {
83
- return sha256(absPath);
84
- }
85
- function backupDirForPath(ctx, absPath) {
86
- const bdir = ctx.backupDir ?? defaultBackupDir();
87
- const key = keyFromPath(absPath);
88
- return { bdir, key, keyDir: path.join(bdir, key) };
89
- }
90
- function formatBackupTs() {
91
- return new Date().toISOString().replace(/[:.]/g, '-');
92
- }
93
- async function restoreLatestBackup(absPath, ctx) {
94
- const { key, keyDir } = backupDirForPath(ctx, absPath);
95
- const legacyDir = ctx.backupDir ?? defaultBackupDir();
96
- const latestInDir = async (dir) => {
97
- const ents = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
98
- return ents
99
- .filter((e) => e.isFile())
100
- .map((e) => e.name)
101
- .filter((n) => n.endsWith('.bak'))
102
- .sort()
103
- .reverse()[0];
104
- };
105
- let bakDir = keyDir;
106
- let bakFile = await latestInDir(keyDir);
107
- if (!bakFile) {
108
- // Compatibility with older flat backup format (without nested key dir).
109
- const ents = await fs.readdir(legacyDir, { withFileTypes: true }).catch(() => []);
110
- const legacy = ents
111
- .filter((e) => e.isFile())
112
- .map((e) => e.name)
113
- .filter((n) => n.startsWith(`${key}.`) && !n.endsWith('.json'))
114
- .sort()
115
- .reverse()[0];
116
- if (legacy) {
117
- bakFile = legacy;
118
- bakDir = legacyDir;
119
- }
120
- }
121
- if (!bakFile) {
122
- throw new Error(`undo: no backups found for ${absPath} in ${legacyDir}`);
123
- }
124
- const bakPath = path.join(bakDir, bakFile);
125
- const buf = await fs.readFile(bakPath);
126
- // backup current file before restoring
127
- await backupFile(absPath, ctx);
128
- await atomicWrite(absPath, buf);
129
- return `restored ${absPath} from backup ${bakPath}`;
130
- }
131
80
  function stripAnsi(s) {
132
81
  // eslint-disable-next-line no-control-regex
133
82
  return s
@@ -217,48 +166,6 @@ function truncateBytes(s, maxBytes, totalBytesHint) {
217
166
  const cut = b.subarray(0, maxBytes);
218
167
  return { text: cut.toString('utf8') + `\n[truncated, ${total} bytes total]`, truncated: true };
219
168
  }
220
- async function rotateBackups(absPath, ctx) {
221
- const { keyDir } = backupDirForPath(ctx, absPath);
222
- const limit = ctx.maxBackupsPerFile ?? DEFAULT_MAX_BACKUPS_PER_FILE;
223
- if (limit <= 0)
224
- return;
225
- await fs.mkdir(keyDir, { recursive: true });
226
- const ents = await fs.readdir(keyDir, { withFileTypes: true }).catch(() => []);
227
- const backups = ents
228
- .filter((e) => e.isFile())
229
- .map((e) => e.name)
230
- .filter((n) => n.endsWith('.bak'))
231
- .sort(); // oldest → newest due to ISO timestamp
232
- const toDelete = backups.length > limit ? backups.slice(0, backups.length - limit) : [];
233
- for (const name of toDelete) {
234
- const bak = path.join(keyDir, name);
235
- const meta = path.join(keyDir, `${name.replace(/\.bak$/, '')}.meta.json`);
236
- await fs.rm(bak, { force: true }).catch(() => { });
237
- await fs.rm(meta, { force: true }).catch(() => { });
238
- }
239
- }
240
- async function backupFile(absPath, ctx) {
241
- const { bdir, keyDir } = backupDirForPath(ctx, absPath);
242
- await fs.mkdir(bdir, { recursive: true });
243
- await fs.mkdir(keyDir, { recursive: true });
244
- // Auto-create .gitignore in state dir to prevent backups from being committed
245
- const gitignorePath = path.join(bdir, '.gitignore');
246
- await fs.writeFile(gitignorePath, '*\n', { flag: 'wx' }).catch(() => { });
247
- // 'wx' flag = create only if doesn't exist, silently skip if it does
248
- const st = await fs.stat(absPath).catch(() => null);
249
- if (!st || !st.isFile())
250
- return;
251
- const content = await fs.readFile(absPath);
252
- const hash = crypto.createHash('sha256').update(content).digest('hex');
253
- const ts = formatBackupTs();
254
- const bakName = `${ts}.bak`;
255
- const metaName = `${ts}.meta.json`;
256
- const bakPath = path.join(keyDir, bakName);
257
- const metaPath = path.join(keyDir, metaName);
258
- await fs.writeFile(bakPath, content);
259
- await fs.writeFile(metaPath, JSON.stringify({ original_path: absPath, timestamp: ts, size: st.size, sha256_before: hash }, null, 2) + '\n', 'utf8');
260
- await rotateBackups(absPath, ctx);
261
- }
262
169
  async function checkpointReplay(ctx, payload) {
263
170
  if (!ctx.replay)
264
171
  return '';
@@ -279,39 +186,6 @@ async function checkpointReplay(ctx, payload) {
279
186
  return ` replay_skipped: ${e?.message ?? String(e)}`;
280
187
  }
281
188
  }
282
- export async function atomicWrite(absPath, data) {
283
- const dir = path.dirname(absPath);
284
- await fs.mkdir(dir, { recursive: true });
285
- // Capture original permissions before overwriting
286
- const origStat = await fs.stat(absPath).catch(() => null);
287
- const origMode = origStat?.mode;
288
- const tmp = path.join(dir, `.${path.basename(absPath)}.idlehands.tmp.${process.pid}.${Date.now()}`);
289
- await fs.writeFile(tmp, data);
290
- // Restore original file mode bits if the file existed
291
- if (origMode != null) {
292
- await fs.chmod(tmp, origMode & 0o7777).catch(() => { });
293
- }
294
- await fs.rename(tmp, absPath);
295
- }
296
- export async function undo_path(ctx, args) {
297
- const directPath = args?.path === undefined ? undefined : String(args.path);
298
- const p = directPath ? resolvePath(ctx, directPath) : ctx.lastEditedPath;
299
- if (!p)
300
- throw new Error('undo: missing path');
301
- const absCwd = path.resolve(ctx.cwd);
302
- const redactedPath = redactPath(p, absCwd);
303
- if (!ctx.noConfirm && ctx.confirm) {
304
- const ok = await ctx.confirm(`Restore latest backup for:\n ${redactedPath}\nThis will overwrite the current file. Proceed? (y/N) `);
305
- if (!ok)
306
- return 'undo: cancelled';
307
- }
308
- if (!ctx.noConfirm && !ctx.confirm) {
309
- throw new Error('undo: confirmation required (run with --no-confirm/--yolo or in interactive mode)');
310
- }
311
- if (ctx.dryRun)
312
- return `dry-run: would restore latest backup for ${redactedPath}`;
313
- return await restoreLatestBackup(p, ctx);
314
- }
315
189
  export async function read_file(ctx, args) {
316
190
  const p = resolvePath(ctx, args?.path);
317
191
  const absCwd = path.resolve(ctx.cwd);
@@ -686,82 +560,8 @@ export async function edit_file(ctx, args) {
686
560
  const readback = mutationReadback(next, editStartLine, editEndLine);
687
561
  return `edited ${redactedPath} (replace_all=${replaceAll})${replayNote}${cwdWarning}${readback}`;
688
562
  }
689
- function normalizePatchPath(p) {
690
- let s = String(p ?? '').trim();
691
- if (!s || s === '/dev/null')
692
- return '';
693
- // Strip quotes some generators add
694
- s = s.replace(/^"|"$/g, '');
695
- // Drop common diff prefixes
696
- s = s.replace(/^[ab]\//, '').replace(/^\.\/+/, '');
697
- // Normalize to posix separators for diffs
698
- s = s.replace(/\\/g, '/');
699
- const norm = path.posix.normalize(s);
700
- if (norm.startsWith('../') || norm === '..' || norm.startsWith('/')) {
701
- throw new Error(`apply_patch: unsafe path in patch: ${JSON.stringify(s)}`);
702
- }
703
- return norm;
704
- }
705
- function extractTouchedFilesFromPatch(patchText) {
706
- const paths = [];
707
- const created = new Set();
708
- const deleted = new Set();
709
- let pendingOld = null;
710
- let pendingNew = null;
711
- const seen = new Set();
712
- const lines = String(patchText ?? '').split(/\r?\n/);
713
- for (const line of lines) {
714
- // Primary: git-style header
715
- if (line.startsWith('diff --git ')) {
716
- const m = /^diff --git\s+a\/(.+?)\s+b\/(.+?)\s*$/.exec(line);
717
- if (m) {
718
- const aPath = normalizePatchPath(m[1]);
719
- const bPath = normalizePatchPath(m[2]);
720
- const use = bPath || aPath;
721
- if (use && !seen.has(use)) {
722
- seen.add(use);
723
- paths.push(use);
724
- }
725
- }
726
- pendingOld = null;
727
- pendingNew = null;
728
- continue;
729
- }
730
- // Fallback: unified diff headers
731
- if (line.startsWith('--- ')) {
732
- pendingOld = line.slice(4).trim();
733
- continue;
734
- }
735
- if (line.startsWith('+++ ')) {
736
- pendingNew = line.slice(4).trim();
737
- const oldP = pendingOld ? pendingOld.replace(/^a\//, '').trim() : '';
738
- const newP = pendingNew ? pendingNew.replace(/^b\//, '').trim() : '';
739
- const oldIsDevNull = oldP === '/dev/null';
740
- const newIsDevNull = newP === '/dev/null';
741
- if (!newIsDevNull) {
742
- const rel = normalizePatchPath(newP);
743
- if (rel && !seen.has(rel)) {
744
- seen.add(rel);
745
- paths.push(rel);
746
- }
747
- if (oldIsDevNull)
748
- created.add(rel);
749
- }
750
- if (!oldIsDevNull && newIsDevNull) {
751
- const rel = normalizePatchPath(oldP);
752
- if (rel && !seen.has(rel)) {
753
- seen.add(rel);
754
- paths.push(rel);
755
- }
756
- deleted.add(rel);
757
- }
758
- pendingOld = null;
759
- pendingNew = null;
760
- continue;
761
- }
762
- }
763
- return { paths, created, deleted };
764
- }
563
+ // Patch parsing helpers imported from tools/patch.ts:
564
+ // PatchTouchInfo, normalizePatchPath, extractTouchedFilesFromPatch
765
565
  async function runCommandWithStdin(cmd, cmdArgs, stdinText, cwd, maxOutBytes) {
766
566
  return await new Promise((resolve, reject) => {
767
567
  const child = spawn(cmd, cmdArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -1605,86 +1405,8 @@ export async function vault_search(ctx, args) {
1605
1405
  export async function sys_context(ctx, args) {
1606
1406
  return sysContextTool(ctx, args);
1607
1407
  }
1608
- /**
1609
- * Check if a target path is within a directory.
1610
- * Handles the classic root directory edge case: when dir is `/`, every absolute path is valid.
1611
- */
1612
- function isWithinDir(target, dir) {
1613
- if (dir === '/')
1614
- return target.startsWith('/') && !target.includes('..');
1615
- const rel = path.relative(dir, target);
1616
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
1617
- }
1618
- function resolvePath(ctx, p) {
1619
- if (typeof p !== 'string' || !p.trim())
1620
- throw new Error('missing path');
1621
- return path.resolve(ctx.cwd, p);
1622
- }
1623
- /**
1624
- * Redact a path for safe output.
1625
- * - Paths within cwd are shown as relative paths
1626
- * - Paths outside cwd are redacted as [outside-cwd]/basename
1627
- */
1628
- function redactPath(filePath, absCwd) {
1629
- const resolved = path.resolve(filePath);
1630
- if (isWithinDir(resolved, absCwd)) {
1631
- return path.relative(absCwd, resolved);
1632
- }
1633
- const basename = path.basename(resolved);
1634
- return `[outside-cwd]/${basename}`;
1635
- }
1636
- /**
1637
- * Check if a resolved path is outside the working directory.
1638
- * Returns a model-visible warning string if so, empty string otherwise.
1639
- */
1640
- function checkCwdWarning(tool, resolvedPath, ctx) {
1641
- const absCwd = path.resolve(ctx.cwd);
1642
- if (isWithinDir(resolvedPath, absCwd))
1643
- return '';
1644
- const warning = `\n[WARNING] Path "${resolvedPath}" is OUTSIDE the working directory "${absCwd}". You MUST use relative paths and work within the project directory. Do NOT create or edit files outside the cwd.`;
1645
- console.warn(`[warning] ${tool}: path "${resolvedPath}" is outside the working directory "${absCwd}".`);
1646
- return warning;
1647
- }
1648
- /**
1649
- * Hard guard for mutating file tools in normal code mode.
1650
- * In code mode, writing outside cwd is always blocked to prevent accidental edits
1651
- * in the wrong repository. System mode keeps broader path freedom for /etc workflows.
1652
- */
1653
- function enforceMutationWithinCwd(tool, resolvedPath, ctx) {
1654
- if (ctx.mode === 'sys')
1655
- return;
1656
- const absTarget = path.resolve(resolvedPath);
1657
- const absCwd = path.resolve(ctx.cwd);
1658
- const roots = (ctx.allowedWriteRoots ?? []).map((r) => path.resolve(r));
1659
- const allowAny = roots.includes('/');
1660
- // If session requires explicit /dir pinning, block all mutations until pinned.
1661
- // Exception: if cwd matches one of the repo candidates, auto-allow.
1662
- if (ctx.requireDirPinForMutations && !ctx.dirPinned) {
1663
- const candidates = ctx.repoCandidates ?? [];
1664
- const cwdMatchesCandidate = candidates.some((c) => {
1665
- const absCandidate = path.resolve(c);
1666
- return absCwd === absCandidate || isWithinDir(absCwd, absCandidate);
1667
- });
1668
- if (!cwdMatchesCandidate) {
1669
- const hint = candidates.length ? ` Candidates: ${candidates.slice(0, 8).join(', ')}` : '';
1670
- throw new Error(`${tool}: BLOCKED — multiple repository candidates detected. Set repo root explicitly with /dir <path> before editing files.${hint}`);
1671
- }
1672
- }
1673
- // Respect allowed roots first.
1674
- if (roots.length > 0 && !allowAny) {
1675
- const inAllowed = roots.some((root) => isWithinDir(absTarget, root));
1676
- if (!inAllowed) {
1677
- throw new Error(`${tool}: BLOCKED — path "${absTarget}" is outside allowed directories: ${roots.join(', ')}`);
1678
- }
1679
- }
1680
- // If / is explicitly allowed, permit filesystem-wide writes after pin policy above.
1681
- if (allowAny)
1682
- return;
1683
- // In code mode, keep edits scoped to current working directory unless explicitly sys mode.
1684
- if (!isWithinDir(absTarget, absCwd)) {
1685
- throw new Error(`${tool}: BLOCKED — path "${absTarget}" is outside the working directory "${absCwd}". Run /dir <project-root> first, then retry with relative paths.`);
1686
- }
1687
- }
1408
+ // Path safety helpers imported from tools/path-safety.ts:
1409
+ // isWithinDir, resolvePath, redactPath, checkCwdWarning, enforceMutationWithinCwd
1688
1410
  async function hasRg() {
1689
1411
  const isWin = process.platform === 'win32';
1690
1412
  if (!isWin) {