@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.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.
Files changed (55) hide show
  1. package/README.md +36 -2
  2. package/dist/bin/ai.js +134 -18
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +3 -3
  6. package/dist/src/api/browser-login.js +0 -16
  7. package/dist/src/api/chat.js +57 -11
  8. package/dist/src/api/http.js +49 -1
  9. package/dist/src/api/models.js +26 -20
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +11 -6
  19. package/dist/src/markdown-renderer.js +1 -1
  20. package/dist/src/patcher.js +1 -3
  21. package/dist/src/scanner.js +50 -12
  22. package/dist/src/scratch-dir.js +57 -0
  23. package/dist/src/secret-preview.js +0 -10
  24. package/dist/src/session-safety.js +0 -19
  25. package/dist/src/session-store.js +0 -1
  26. package/dist/src/todo-list.js +106 -0
  27. package/dist/src/tool-executor.js +159 -18
  28. package/dist/src/tools/delete-file.js +1 -1
  29. package/dist/src/tools/index.js +6 -0
  30. package/dist/src/tools/patch-file.js +3 -2
  31. package/dist/src/tools/path-suggest.js +81 -8
  32. package/dist/src/tools/read-document.js +2 -2
  33. package/dist/src/tools/read-file.js +14 -7
  34. package/dist/src/tools/replace-document-text.js +3 -11
  35. package/dist/src/tools/restore-checkpoint.js +1 -1
  36. package/dist/src/tools/run-command.js +83 -16
  37. package/dist/src/tools/run-node-script.js +3 -1
  38. package/dist/src/tools/shell-job-kill.js +48 -0
  39. package/dist/src/tools/shell-job-output.js +51 -0
  40. package/dist/src/tools/str-replace.js +3 -2
  41. package/dist/src/tools/undo-edit.js +1 -1
  42. package/dist/src/tools/update-todos.js +27 -0
  43. package/dist/src/tools/write-file.js +1 -1
  44. package/dist/src/tree-sitter-runtime.js +8 -1
  45. package/dist/src/ui/repl.js +313 -23
  46. package/dist/src/ui/tui/bridge.js +0 -4
  47. package/dist/src/ui/tui/build-frame.js +220 -24
  48. package/dist/src/ui/tui/shell-input.js +33 -4
  49. package/dist/src/ui/tui/terminal-title.js +81 -0
  50. package/dist/src/version.js +0 -6
  51. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  52. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  53. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  54. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  55. package/package.json +14 -15
@@ -1,11 +1,25 @@
1
- import chalk from 'chalk';
2
- import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
1
+ import chalk from './colors.js';
3
2
  import { execFileSync, spawn } from 'child_process';
4
3
  import { existsSync, statSync } from 'fs';
4
+ import { createRequire } from 'node:module';
5
5
  import os from 'os';
6
6
  import path from 'path';
7
7
  import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
8
8
  import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
9
+ import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
10
+ const requireFromHere = createRequire(import.meta.url);
11
+ let nodePtyCache;
12
+ function loadNodePty() {
13
+ if (nodePtyCache !== undefined)
14
+ return nodePtyCache;
15
+ try {
16
+ nodePtyCache = requireFromHere('@lydell/node-pty');
17
+ }
18
+ catch {
19
+ nodePtyCache = null;
20
+ }
21
+ return nodePtyCache;
22
+ }
9
23
  const COMMON_TOOLCHAIN_BIN_DIRS = ['/usr/local/go/bin'];
10
24
  function detectVenvBin(dir) {
11
25
  for (const name of ['.venv', 'venv', 'env']) {
@@ -204,6 +218,7 @@ function isExistingDirectory(absPath) {
204
218
  return false;
205
219
  }
206
220
  }
221
+ const OS_TEMP_BLOCK_MARKER = 'os-temp:';
207
222
  function getBlockedOsTempInspection(rawToken, rootDir) {
208
223
  const token = normalizeToken(rawToken);
209
224
  if (!token || !path.isAbsolute(token))
@@ -211,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
211
226
  const resolved = path.resolve(token);
212
227
  if (!isInsideOsTemp(resolved))
213
228
  return null;
229
+ if (isInsideTheGitAiScratch(resolved))
230
+ return null;
214
231
  if (rootDir) {
215
232
  const resolvedRoot = path.resolve(rootDir);
216
233
  if (resolved === resolvedRoot ||
@@ -221,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
221
238
  if (resolved === path.resolve(os.tmpdir()) ||
222
239
  hasPathGlob(token) ||
223
240
  isExistingDirectory(resolved)) {
224
- return path.basename(os.tmpdir()) || os.tmpdir();
241
+ return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
225
242
  }
226
243
  return null;
227
244
  }
@@ -237,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
237
254
  const resolved = path.isAbsolute(token)
238
255
  ? path.resolve(token)
239
256
  : path.resolve(baseDir ?? rootDir, token);
257
+ const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
258
+ if (osTempResolved)
259
+ return osTempResolved;
240
260
  return getBlockedProjectPathDir(resolved, rootDir);
241
261
  }
242
262
  if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
@@ -373,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
373
393
  }
374
394
  return null;
375
395
  }
396
+ function expandTempEnvRefs(command) {
397
+ return command
398
+ .replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
399
+ .replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
400
+ }
376
401
  function findIgnoredPathInspection(command, rootDir) {
377
402
  if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
378
403
  return null;
379
404
  }
380
- const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
405
+ const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
381
406
  const baseDir = getCommandBaseDir(haystack, rootDir);
382
407
  const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
383
408
  if (blockedDir) {
@@ -434,7 +459,7 @@ function shouldDropOutputLine(line, rootDir, baseDir) {
434
459
  function isLsDirectoryHeader(line) {
435
460
  return /^\.?\/?.+:$/.test(line) && !line.includes(' ');
436
461
  }
437
- function sanitizeCommandText(command, text, rootDir) {
462
+ export function sanitizeCommandText(command, text, rootDir) {
438
463
  if (!text)
439
464
  return '';
440
465
  const lines = text.split('\n');
@@ -509,7 +534,7 @@ export function cancelActiveCommand() {
509
534
  cancelled: true,
510
535
  });
511
536
  }
512
- function terminateChild(child, signal) {
537
+ export function terminateChild(child, signal) {
513
538
  if (!child?.pid)
514
539
  return;
515
540
  if (process.platform === 'win32') {
@@ -537,10 +562,17 @@ function terminateChild(child, signal) {
537
562
  }
538
563
  }
539
564
  export function getBlockedPathInspectDir(command, rootDir) {
540
- return findIgnoredPathInspection(command, rootDir);
565
+ const blocked = findIgnoredPathInspection(command, rootDir);
566
+ return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
567
+ ? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
568
+ : blocked;
541
569
  }
542
570
  export function getBlockedCommandReason(command, hasTimeout, rootDir) {
543
571
  const ignoredDir = findIgnoredPathInspection(command, rootDir);
572
+ if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
573
+ const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
574
+ return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
575
+ }
544
576
  if (ignoredDir) {
545
577
  return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
546
578
  }
@@ -559,7 +591,7 @@ export function getBlockedCommandReason(command, hasTimeout, rootDir) {
559
591
  }
560
592
  return null;
561
593
  }
562
- function commandUsesSudo(command) {
594
+ export function commandUsesSudo(command) {
563
595
  return /\bsudo\b/.test(getUnquotedShellText(command));
564
596
  }
565
597
  export function sudoPromptFromTail(text) {
@@ -587,7 +619,7 @@ function redactSecrets(text, secrets) {
587
619
  }
588
620
  return redacted;
589
621
  }
590
- function buildCommandEnv(cwd) {
622
+ export function buildCommandEnv(cwd) {
591
623
  const venvBin = detectVenvBin(cwd);
592
624
  const envPath = buildCommandPath(process.env.PATH, venvBin ? [venvBin] : []);
593
625
  return {
@@ -602,12 +634,13 @@ function buildCommandEnv(cwd) {
602
634
  npm_config_fund: 'false',
603
635
  npm_config_audit: 'false',
604
636
  NUXI_INIT_SKIP_PROMPT: 'true',
637
+ THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
605
638
  };
606
639
  }
607
640
  function sanitizePtyOutput(command, output, cwd, secrets) {
608
641
  return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
609
642
  }
610
- async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword) {
643
+ async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty) {
611
644
  return new Promise((resolve) => {
612
645
  let output = '';
613
646
  let timedOut = false;
@@ -623,7 +656,7 @@ async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, reques
623
656
  const args = process.platform === 'win32'
624
657
  ? ['/d', '/s', '/c', command]
625
658
  : ['-lc', command];
626
- const child = pty.spawn(shell, args, {
659
+ const child = nodePty.spawn(shell, args, {
627
660
  cols: 120,
628
661
  rows: 30,
629
662
  cwd,
@@ -776,7 +809,10 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
776
809
  }
777
810
  const exploratory = isExploratoryCommand(command);
778
811
  if (requestSudoPassword && commandUsesSudo(command)) {
779
- return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword);
812
+ const nodePty = loadNodePty();
813
+ if (nodePty) {
814
+ return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
815
+ }
780
816
  }
781
817
  return new Promise((resolve) => {
782
818
  let stdout = '';
@@ -1,9 +1,5 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
2
2
  import { getCliVersion, getPlatformTag } from './version.js';
3
- // The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
4
- // a terminal. The one thing that genuinely differs is the terminal's paste
5
- // shortcut, so surface the one for the host OS (right-click paste works
6
- // everywhere regardless).
7
3
  function pasteShortcutForPlatform() {
8
4
  switch (process.platform) {
9
5
  case 'darwin':
@@ -77,6 +73,10 @@ const HELP_MARKDOWN = [
77
73
  '- `/model` — list supported models and pick one',
78
74
  '- `/model <id>` — switch the active model without clearing history',
79
75
  '- `/resume` — resume a saved session for this repo',
76
+ '- `/jobs` — manage long-running commands like dev servers and watchers:',
77
+ ' browse them, press Enter to expand one and read its output, k to stop it',
78
+ '- `/jobs output <id>` — print one job\'s full captured output',
79
+ '- `/jobs kill <id>` — stop one background job',
80
80
  '- `/clear` — clear the current conversation history',
81
81
  '- `/exit` — quit the session',
82
82
  '',
@@ -89,6 +89,12 @@ const HELP_MARKDOWN = [
89
89
  ' command and keeps the password masked and local.',
90
90
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
91
91
  ' edit for the whole session — use with care.',
92
+ '- Long-running commands (e.g. dev servers) can run as managed background',
93
+ ' jobs after the same approval as any other command. Background output',
94
+ ' stays quiet once the model has responded; the footer shows only a compact',
95
+ ' shell-running indicator, `/jobs` lists, inspects, and kills jobs, and',
96
+ ' killed jobs disappear immediately. Every job is killed when the session',
97
+ ' ends.',
92
98
  '- File and shell operations are confined to the target repo root.',
93
99
  '- Sensitive directories (`.git`, `node_modules`, build output) are',
94
100
  ' never indexed.',
@@ -104,7 +110,6 @@ const HELP_MARKDOWN = [
104
110
  ' message — there is no client-side debug mode by design.',
105
111
  ].join('\n');
106
112
  export function formatAboutCard() {
107
- // Fenced so the column alignment survives terminal markdown rendering.
108
113
  return [
109
114
  '```',
110
115
  'TheGitAI',
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
2
2
  function renderInline(text) {
3
3
  const parts = String(text ?? '').split(/(`[^`]+`)/g);
4
4
  return parts
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
3
3
  import path from 'path';
4
4
  import { createInterface } from 'readline';
@@ -162,7 +162,6 @@ export function writeProjectFile(rootDir, filePath, content) {
162
162
  }
163
163
  }
164
164
  catch {
165
- // If we can't read it for some reason, proceed with write
166
165
  }
167
166
  }
168
167
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -179,7 +178,6 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
179
178
  }
180
179
  }
181
180
  catch {
182
- // If we can't read it for some reason, proceed with write
183
181
  }
184
182
  }
185
183
  mkdirSync(path.dirname(absPath), { recursive: true });
@@ -1,14 +1,12 @@
1
1
  import { execSync } from 'child_process';
2
- import { readFileSync, statSync } from 'fs';
3
- import { glob } from 'glob';
2
+ import { readdirSync, readFileSync, statSync } from 'fs';
4
3
  import path from 'path';
5
4
  import { getNodePrimarySignature, getStructuralChildren, parseRepoSource, } from './tree-sitter-runtime.js';
6
- import { ARTIFACT_FALLBACK_IGNORE_GLOBS, ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
+ import { ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
7
6
  const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
8
7
  const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
9
8
  export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
10
9
  export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
11
- const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
12
10
  export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
13
11
  const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
14
12
  const MAX_CHUNKS = 2000;
@@ -29,16 +27,56 @@ function getFiles(rootDir, { limit = Infinity } = {}) {
29
27
  return Number.isFinite(limit) ? files.slice(0, limit) : files;
30
28
  }
31
29
  catch {
32
- return glob
33
- .sync('**/*', {
34
- cwd: rootDir,
35
- nodir: true,
36
- dot: false,
37
- ignore: FALLBACK_IGNORE,
38
- })
39
- .slice(0, Number.isFinite(limit) ? limit : undefined);
30
+ return walkProjectFilesFallback(rootDir, Number.isFinite(limit) ? limit : Infinity);
40
31
  }
41
32
  }
33
+ const FALLBACK_LOCKFILES = new Set([
34
+ 'package-lock.json',
35
+ 'yarn.lock',
36
+ 'pnpm-lock.yaml',
37
+ ]);
38
+ function isFallbackIgnoredFile(relPath, fileName) {
39
+ if (shouldIgnorePath(relPath))
40
+ return true;
41
+ if (fileName.endsWith('.lock'))
42
+ return true;
43
+ return FALLBACK_LOCKFILES.has(fileName);
44
+ }
45
+ function walkProjectFilesFallback(rootDir, limit) {
46
+ const results = [];
47
+ const visit = (relDir) => {
48
+ if (results.length >= limit)
49
+ return;
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(path.join(rootDir, relDir), {
53
+ withFileTypes: true,
54
+ });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ for (const entry of entries) {
60
+ if (results.length >= limit)
61
+ return;
62
+ const name = entry.name;
63
+ if (name.startsWith('.'))
64
+ continue;
65
+ const relPath = relDir ? `${relDir}/${name}` : name;
66
+ if (entry.isDirectory()) {
67
+ if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
68
+ continue;
69
+ }
70
+ visit(relPath);
71
+ }
72
+ else if (entry.isFile() && !isFallbackIgnoredFile(relPath, name)) {
73
+ results.push(relPath);
74
+ }
75
+ }
76
+ };
77
+ visit('');
78
+ return results;
79
+ }
42
80
  function shouldSkipFile(relPath, stat) {
43
81
  if (shouldIgnorePath(relPath))
44
82
  return true;
@@ -0,0 +1,57 @@
1
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ let cachedScratchDir = null;
5
+ export function sessionScratchDir() {
6
+ if (!cachedScratchDir) {
7
+ cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
+ }
9
+ return cachedScratchDir;
10
+ }
11
+ function isSquattedScratchRoot(dir) {
12
+ try {
13
+ const st = lstatSync(dir);
14
+ if (st.isSymbolicLink() || !st.isDirectory())
15
+ return true;
16
+ if (process.platform !== 'win32' &&
17
+ typeof process.getuid === 'function' &&
18
+ st.uid !== process.getuid()) {
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function ensureSessionScratchDir() {
28
+ const dir = sessionScratchDir();
29
+ try {
30
+ if (isSquattedScratchRoot(dir)) {
31
+ cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
+ return cachedScratchDir;
33
+ }
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ if (process.platform !== 'win32') {
36
+ chmodSync(dir, 0o700);
37
+ }
38
+ }
39
+ catch {
40
+ }
41
+ return sessionScratchDir();
42
+ }
43
+ export function isInsideTheGitAiScratch(absPath) {
44
+ const tempRoot = path.resolve(os.tmpdir());
45
+ const relPath = path.relative(tempRoot, path.resolve(absPath));
46
+ if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
47
+ return false;
48
+ }
49
+ const first = relPath.split(/[\\/]/, 1)[0] ?? '';
50
+ if (!/^thegitai(?:$|[-.])/i.test(first)) {
51
+ return false;
52
+ }
53
+ if (/[*?[\]{}]/.test(first)) {
54
+ return false;
55
+ }
56
+ return !isSquattedScratchRoot(path.join(tempRoot, first));
57
+ }
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
6
6
  const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
7
7
  const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
8
8
  const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
9
- // Password embedded in a connection-string URL, e.g.
10
- // `postgresql://user:PASS@host`. Redacted from shell output so secrets in
11
- // commands like `cat .env` do not leak into history or telemetry.
12
9
  const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
13
- /** Redact only userinfo passwords in connection-string URLs (zero false positives). */
14
10
  export function redactConnectionStringCredentials(text) {
15
11
  return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
16
12
  }
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
72
68
  const base = path.posix.basename(text.replace(/\\/g, '/'));
73
69
  return DOTENV_BASENAME_PATTERN.test(base);
74
70
  }
75
- /**
76
- * True only for a clean dotenv file we can safely show with keys visible and
77
- * values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
78
- * is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
79
- * raw secret) returns false so the caller keeps the opaque blackout instead.
80
- */
81
71
  export function looksLikeEditableDotenv(content) {
82
72
  PEM_BLOCK_PATTERN.lastIndex = 0;
83
73
  if (PEM_BLOCK_PATTERN.test(content))
@@ -454,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
454
454
  }
455
455
  const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
456
456
  const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
457
- /**
458
- * Redact a dotenv file's values while leaving keys visible. Every assignment's
459
- * value is replaced with a stable, reversible token so the agent can see the
460
- * file's structure and edit it (remove or replace lines) without ever seeing a
461
- * secret value; `resolveRedactionTokens` swaps the real values back on write.
462
- * Comment bodies are tokenized too, because developers routinely leave
463
- * commented-out credentials in dotenv files and those must not leak where the
464
- * opaque preview would have hidden them. Callers must confirm the content is
465
- * clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
466
- * lines reaching here are blanks and comments.
467
- */
468
457
  export function redactDotenvWithStableTokens(state, content, filePath, hash) {
469
458
  const tokens = [];
470
459
  const redactedLines = content.split('\n').map((line) => {
@@ -490,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
490
479
  });
491
480
  return { content: redactedLines.join('\n'), tokens };
492
481
  }
493
- /**
494
- * The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
495
- * emits more tokens than that would evict its own oldest tokens, leaving
496
- * `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
497
- * longer resolve (silently writing the literal token back). So a dotenv file
498
- * with more tokenizable lines than the budget must not use the editable preview
499
- * — the caller falls back to the opaque blackout instead.
500
- */
501
482
  export function dotenvFitsRedactionBudget(content) {
502
483
  let count = 0;
503
484
  for (const line of content.split('\n')) {
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
122
122
  snapshots.push(loadSnapshotFile(filePath, rootDir));
123
123
  }
124
124
  catch {
125
- // Skip corrupted snapshots silently — customers have no actionable debug path here.
126
125
  }
127
126
  }
128
127
  return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -0,0 +1,106 @@
1
+ export const MAX_TODO_ITEMS = 20;
2
+ export const MAX_TODO_TEXT_CHARS = 160;
3
+ let items = [];
4
+ let activeSessionId = null;
5
+ const STATUS_ALIASES = {
6
+ pending: 'pending',
7
+ todo: 'pending',
8
+ not_started: 'pending',
9
+ in_progress: 'in_progress',
10
+ active: 'in_progress',
11
+ doing: 'in_progress',
12
+ completed: 'completed',
13
+ complete: 'completed',
14
+ done: 'completed',
15
+ };
16
+ function normalizeStatus(raw) {
17
+ const key = String(raw ?? '')
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[-\s]+/g, '_');
21
+ return STATUS_ALIASES[key] ?? null;
22
+ }
23
+ export function isCompletedStatus(raw) {
24
+ return normalizeStatus(raw) === 'completed';
25
+ }
26
+ const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
27
+ export function extractTodosArg(args) {
28
+ if (!args || typeof args !== 'object')
29
+ return undefined;
30
+ const record = args;
31
+ if (record.todos !== undefined)
32
+ return record.todos;
33
+ for (const key of TODOS_ARG_ALIASES) {
34
+ if (record[key] !== undefined)
35
+ return record[key];
36
+ }
37
+ return undefined;
38
+ }
39
+ export function setTodoSession(sessionId) {
40
+ const next = String(sessionId ?? '').trim() || null;
41
+ if (activeSessionId !== next) {
42
+ items = [];
43
+ }
44
+ activeSessionId = next;
45
+ }
46
+ export function listTodos() {
47
+ return items.map((item) => ({ ...item }));
48
+ }
49
+ export function getTodoSnapshot() {
50
+ return {
51
+ items: listTodos(),
52
+ completedCount: items.filter((item) => item.status === 'completed').length,
53
+ totalCount: items.length,
54
+ };
55
+ }
56
+ export function clearTodos() {
57
+ items = [];
58
+ }
59
+ export function replaceTodos(raw) {
60
+ if (!Array.isArray(raw)) {
61
+ return { ok: false, error: 'todos must be an array of { text, status } items.' };
62
+ }
63
+ const normalizations = [];
64
+ if (raw.length > MAX_TODO_ITEMS) {
65
+ return {
66
+ ok: false,
67
+ error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
68
+ };
69
+ }
70
+ const next = [];
71
+ let sawInProgress = false;
72
+ for (const [index, entry] of raw.entries()) {
73
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
74
+ return { ok: false, error: `todos[${index}] must be an object with text and status.` };
75
+ }
76
+ const text = String(entry.text ?? '')
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ if (!text) {
80
+ return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
81
+ }
82
+ const status = normalizeStatus(entry.status);
83
+ if (!status) {
84
+ return {
85
+ ok: false,
86
+ error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
87
+ };
88
+ }
89
+ let boundedText = text;
90
+ if (boundedText.length > MAX_TODO_TEXT_CHARS) {
91
+ boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
92
+ normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
93
+ }
94
+ let finalStatus = status;
95
+ if (status === 'in_progress') {
96
+ if (sawInProgress) {
97
+ finalStatus = 'pending';
98
+ normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
99
+ }
100
+ sawInProgress = true;
101
+ }
102
+ next.push({ text: boundedText, status: finalStatus });
103
+ }
104
+ items = next;
105
+ return { ok: true, snapshot: getTodoSnapshot(), normalizations };
106
+ }