@itcase/config 1.6.80 → 1.6.82

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/README.md CHANGED
@@ -90,8 +90,10 @@ npm install --save-dev commitizen @commitlint/cz-commitlint
90
90
  `smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит. Lockfile (`package-lock.json` и аналоги) всегда кладётся в тот же коммит, что и соседний `package.json`; при одном только bump deps агент не вызывается.
91
91
 
92
92
  ```text
93
- [yellow pixel banana animation]
93
+ [random pixel animation]
94
94
  Drafting commit messages… (12s)
95
+ Agent started (Composer)
96
+ Reading src/foo.ts
95
97
 
96
98
  Commits:
97
99
  • feat(other): add smart-commit cli
@@ -146,7 +148,7 @@ CLI ставится в `~/.local/bin`. Логин в Cursor IDE этот CLI н
146
148
 
147
149
  Обновление CLI: `agent update`.
148
150
 
149
- Пока Agent думает, в терминале крутится pixel-анимация (случайно nyan cat или dancing banana) + статус `(Ns)` на одной области экрана (`log-update`). Принудительно: `--cat` / `--banana`. Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
151
+ Пока Agent думает, в терминале крутится pixel-анимация (случайно nyan cat, dancing banana, упячка или Pac-Man) + статус `(Ns)`, а под ними живой лог агента (`log-update`). Пока процесс агента жив, CLI его не убивает по таймауту. Принудительно: `--cat` / `--banana` / `--upyachka` / `--pacman`. Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
150
152
 
151
153
  > **Важно:** в npm есть одноимённый пакет `smart-commit`. Используйте `npm run smart-commit` из проекта с `@itcase/config` — bin берётся из `node_modules`. `npx smart-commit` без локальной установки может подтянуть чужой пакет; CLI перед запуском проверяет, что выполняется именно `@itcase/config`.
152
154
 
@@ -48,10 +48,11 @@ itcase_conflict_marker_files() {
48
48
  }
49
49
 
50
50
  itcase_run_lint_staged() {
51
+ # ACMR drops deletions; include D so removed files stay in the commit.
51
52
  if [ -f "./node_modules/lint-staged/bin/lint-staged.js" ]; then
52
- node ./node_modules/lint-staged/bin/lint-staged.js
53
+ node ./node_modules/lint-staged/bin/lint-staged.js --diff-filter=ACDMR
53
54
  return $?
54
55
  fi
55
56
 
56
- npx lint-staged
57
+ npx lint-staged --diff-filter=ACDMR
57
58
  }
@@ -2,10 +2,21 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
 
4
4
  export default {
5
- '*.(js|jsx|ts|tsx)': (files) => `npx eslint --fix --color ${files.join(' ')}`,
6
- '*.css': (files) =>
7
- `npx stylelint --fix --color --formatter verbose ${files.join(' ')}`,
8
- '*.mdx': (files) => `npx eslint --fix --color ${files.join(' ')}`,
5
+ '*.(js|jsx|ts|tsx)': (files) => {
6
+ const existing = files.filter((file) => existsSync(file))
7
+ if (!existing.length) return 'true'
8
+ return `npx eslint --fix --color ${existing.join(' ')}`
9
+ },
10
+ '*.css': (files) => {
11
+ const existing = files.filter((file) => existsSync(file))
12
+ if (!existing.length) return 'true'
13
+ return `npx stylelint --fix --color --formatter verbose ${existing.join(' ')}`
14
+ },
15
+ '*.mdx': (files) => {
16
+ const existing = files.filter((file) => existsSync(file))
17
+ if (!existing.length) return 'true'
18
+ return `npx eslint --fix --color ${existing.join(' ')}`
19
+ },
9
20
  'package.json': (files) => {
10
21
  // Проверяем, есть ли package.json в корне репозитория
11
22
  const rootPackageJson = resolve(process.cwd(), 'package.json')
@@ -3,17 +3,27 @@ import { resolve } from 'node:path'
3
3
 
4
4
  export default {
5
5
  '*.(ts|tsx)': (files) => {
6
- const tsFiles = files.filter((f) => /\.(ts|tsx)$/.test(f))
6
+ const existing = files.filter((file) => existsSync(file))
7
+ const tsFiles = existing.filter((file) => /\.(ts|tsx)$/.test(file))
7
8
  const commands = []
8
9
  if (tsFiles.length > 0) {
9
10
  commands.push('tsc --noEmit --pretty')
10
11
  }
11
- commands.push(`npx eslint --fix ${files.join(' ')}`)
12
- return commands
12
+ if (existing.length > 0) {
13
+ commands.push(`npx eslint --fix ${existing.join(' ')}`)
14
+ }
15
+ return commands.length ? commands : 'true'
16
+ },
17
+ '*.css': (files) => {
18
+ const existing = files.filter((file) => existsSync(file))
19
+ if (!existing.length) return 'true'
20
+ return `npx stylelint --fix --color --formatter verbose ${existing.join(' ')}`
21
+ },
22
+ '*.mdx': (files) => {
23
+ const existing = files.filter((file) => existsSync(file))
24
+ if (!existing.length) return 'true'
25
+ return `npx eslint --fix ${existing.join(' ')}`
13
26
  },
14
- '*.css': (files) =>
15
- `npx stylelint --fix --color --formatter verbose ${files.join(' ')}`,
16
- '*.mdx': (files) => `npx eslint --fix ${files.join(' ')}`,
17
27
  'package.json': (files) => {
18
28
  // Проверяем, есть ли package.json в корне репозитория
19
29
  const rootPackageJson = resolve(process.cwd(), 'package.json')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.80",
3
+ "version": "1.6.82",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -63,23 +63,23 @@
63
63
  "@svgr/plugin-jsx": "^8.1.0",
64
64
  "@svgr/plugin-svgo": "^8.1.0",
65
65
  "@svgr/webpack": "^8.1.0",
66
- "autoprefixer": "^10.5.4",
66
+ "autoprefixer": "^10.5.5",
67
67
  "chokidar-cli": "^3.0.0",
68
68
  "commitizen": "^4.3.2",
69
69
  "conventional-changelog-conventionalcommits": "9.3.1",
70
- "cssnano": "^9.0.2",
71
- "cssnano-preset-default": "^9.0.2",
72
- "inquirer": "^14.2.0",
70
+ "cssnano": "^9.0.4",
71
+ "cssnano-preset-default": "^9.0.4",
72
+ "inquirer": "^14.2.2",
73
73
  "log-update": "^8.0.0",
74
- "postcss": "^8.5.26",
74
+ "postcss": "^8.5.28",
75
75
  "postcss-aspect-ratio-polyfill": "^2.0.0",
76
76
  "postcss-clamp": "^4.1.0",
77
- "postcss-cli": "^11.0.1",
77
+ "postcss-cli": "^12.0.0",
78
78
  "postcss-color-hsla-fallback": "^1.0.0",
79
79
  "postcss-combine-duplicated-selectors": "^10.0.3",
80
80
  "postcss-convert-unit": "^0.1.3",
81
81
  "postcss-dark-theme-class": "^2.0.0",
82
- "postcss-discard-duplicates": "^9.0.1",
82
+ "postcss-discard-duplicates": "^9.0.2",
83
83
  "postcss-each": "^1.1.0",
84
84
  "postcss-easings": "^4.0.0",
85
85
  "postcss-extend-rule": "^4.0.0",
@@ -95,7 +95,7 @@
95
95
  "postcss-nested-ancestors": "^3.0.0",
96
96
  "postcss-normalize": "^13.0.1",
97
97
  "postcss-prepend-imports": "^1.0.1",
98
- "postcss-preset-env": "^11.5.1",
98
+ "postcss-preset-env": "^11.5.2",
99
99
  "postcss-pxtorem": "^6.1.0",
100
100
  "postcss-sort-media-queries": "^6.7.1",
101
101
  "postcss-unit": "^0.0.3",
@@ -109,17 +109,17 @@
109
109
  "rollup-plugin-peer-deps-external": "^2.2.4",
110
110
  "rollup-preserve-directives": "^1.1.3",
111
111
  "semantic-release": "^25.0.9",
112
- "semantic-release-lerna": "^3.6.3"
112
+ "semantic-release-lerna": "^3.6.4"
113
113
  },
114
114
  "devDependencies": {
115
- "@itcase/lint": "^1.1.122",
115
+ "@itcase/lint": "^1.1.123",
116
116
  "eslint": "9.39.2",
117
117
  "husky": "^9.1.7",
118
- "inquirer": "^14.2.0",
119
- "lint-staged": "^17.4.1",
118
+ "inquirer": "^14.2.2",
119
+ "lint-staged": "^17.5.0",
120
120
  "prettier": "^3.9.6",
121
- "storybook": "^10.5.10",
122
- "stylelint": "^17.14.1",
121
+ "storybook": "^10.6.0",
122
+ "stylelint": "^17.15.0",
123
123
  "typescript": "^6.0.3"
124
124
  }
125
125
  }
@@ -17,9 +17,13 @@ import {
17
17
  } from '../commitlint/commitlintCommon.js'
18
18
  import { getBananaFrame } from './dancing-banana.js'
19
19
  import { getNyanFrame } from './nyan-cat.js'
20
+ import { getPacmanFrame } from './pacman.js'
21
+ import { getUpyachkaFrame } from './upyachka.js'
20
22
 
21
23
  const PACKAGE_NAME = '@itcase/config'
22
24
  const CLI_RELATIVE = 'smart-commit/cli.js'
25
+ // Cheap Cursor Models pool — commit messages do not need Auto / frontier models.
26
+ const DEFAULT_MODEL = 'composer-2.5'
23
27
 
24
28
  const MAX_DIFF = 80_000
25
29
  const LOCKFILE_NAMES = new Set([
@@ -30,10 +34,14 @@ const LOCKFILE_NAMES = new Set([
30
34
  'bun.lock',
31
35
  'bun.lockb',
32
36
  ])
33
- const AGENT_TIMEOUT_MS = 120_000
34
37
  const AGENT_STEP_MS = 2_500
38
+ const AGENT_STILL_WORKING_S = 120
39
+ const AGENT_LOG_ROWS = 8
40
+ const AGENT_LOG_MAX = 160
35
41
  const NYAN_FRAME_MS = 90
36
42
  const BANANA_FRAME_MS = 100
43
+ const PACMAN_FRAME_MS = 120
44
+ const UPYACHKA_FRAME_MS = 100
37
45
  const INDEX_LOCK_RETRIES = 8
38
46
  const INDEX_LOCK_WAIT_MS = 250
39
47
  const STALE_LOCK_MS = 1500
@@ -43,13 +51,15 @@ const AGENT_WAIT_STEPS = [
43
51
  'Waiting for response',
44
52
  ]
45
53
  const HELP = `smart-commit [--dry-run] [--yes|-y] [--type t] [--model id] [--verbose]
46
- [--cat|--nyan] [--banana]
54
+ [--cat|--nyan] [--banana] [--upyachka] [--pacman]
47
55
 
48
56
  Cursor Agent → commit messages by SCOPES → [y/N] → git commit
49
57
  Run via: npm exec smart-commit (from project with @itcase/config)
50
58
  Needs: agent login (or CURSOR_API_KEY) + commitlint.config.* with SCOPES
59
+ Default model: ${DEFAULT_MODEL} (override with --model / SMART_COMMIT_MODEL).
51
60
 
52
- Progress sprite is random (nyan cat or banana). Force with --cat or --banana.
61
+ Progress sprite is random (nyan cat, banana, upyachka, or Pac-Man).
62
+ Force with --cat, --banana, --upyachka, or --pacman. Agent log is below.
53
63
  `
54
64
  const AGENT_MISSING = `Cursor Agent CLI not found — smart-commit cannot run.
55
65
 
@@ -97,7 +107,7 @@ async function main(argv) {
97
107
  (progress) =>
98
108
  askAgent(
99
109
  cwd,
100
- option.model || process.env.SMART_COMMIT_MODEL || '',
110
+ option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
101
111
  prompt,
102
112
  option.verbose,
103
113
  progress.onAgentActivity,
@@ -213,6 +223,8 @@ function parseArgs(argv) {
213
223
  else if (arg === '--staged') option.staged = true
214
224
  else if (arg === '--cat' || arg === '--nyan') option.sprite = 'cat'
215
225
  else if (arg === '--banana') option.sprite = 'banana'
226
+ else if (arg === '--upyachka') option.sprite = 'upyachka'
227
+ else if (arg === '--pacman') option.sprite = 'pacman'
216
228
  else if (arg === '--type') option.type = argv[++i] || ''
217
229
  else if (arg === '--model') option.model = argv[++i] || ''
218
230
  else if (arg.startsWith('--type=')) option.type = arg.slice(7)
@@ -223,8 +235,9 @@ function parseArgs(argv) {
223
235
  }
224
236
 
225
237
  function pickSprite(forced) {
226
- if (forced === 'cat' || forced === 'banana') return forced
227
- return Math.random() < 0.5 ? 'cat' : 'banana'
238
+ const sprites = ['cat', 'banana', 'upyachka', 'pacman']
239
+ if (sprites.includes(forced)) return forced
240
+ return sprites[Math.floor(Math.random() * sprites.length)]
228
241
  }
229
242
 
230
243
  function assertItcaseConfigPackage(cwd) {
@@ -352,6 +365,7 @@ function listFiles(cwd, option) {
352
365
  ...new Set([
353
366
  ...lines(cwd, ['diff', '--cached', '--name-only', '--diff-filter=ACDMR']),
354
367
  ...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
368
+ ...lines(cwd, ['ls-files', '--deleted']),
355
369
  ...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
356
370
  ]),
357
371
  ]
@@ -385,14 +399,15 @@ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
385
399
  Subject rules:
386
400
  ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
387
401
  Prefer one commit when changes belong to the same feature.
388
- Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
402
+ Split by scope. Each listed file in exactly one commit — including deleted files (D) and new/untracked files (??).
389
403
  Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
390
404
  Do not add Co-authored-by or Made-with trailers.
391
405
  ${type ? `Prefer type "${type}".\n` : ''}Files:
392
406
  ${files
393
407
  .map((file) => {
394
408
  const status =
395
- statusByFile.get(file) || (isUntracked(cwd, file) ? '??' : ' M')
409
+ statusByFile.get(file) ||
410
+ (isUntracked(cwd, file) ? '??' : isOnDisk(cwd, file) ? ' M' : 'D')
396
411
  const lockNote = isLockfile(file) ? ', lockfile' : ''
397
412
  return `- ${file} (${status}${lockNote})`
398
413
  })
@@ -460,49 +475,109 @@ async function withAgentProgress(context, callback) {
460
475
  let phase = 'early'
461
476
  let receivedChars = 0
462
477
  let frameIndex = 0
478
+ const logLines = []
479
+ const streamByKey = { assistant: -1, thinking: -1 }
463
480
 
464
- const onAgentActivity = (charCount) => {
465
- if (charCount > 0) {
466
- receivedChars = charCount
481
+ const elapsedSeconds = () => Math.floor((Date.now() - startedAt) / 1000)
482
+
483
+ const onAgentActivity = (activity = {}) => {
484
+ if (activity.charCount > 0) {
485
+ receivedChars = activity.charCount
467
486
  phase = 'receiving'
468
487
  }
488
+ if (!activity.log) return
489
+ if (activity.stream) {
490
+ const key = activity.streamKey || 'assistant'
491
+ const index = streamByKey[key]
492
+ if (index >= 0 && index < logLines.length) logLines[index] = activity.log
493
+ else {
494
+ logLines.push(activity.log)
495
+ streamByKey[key] = logLines.length - 1
496
+ }
497
+ } else if (logLines[logLines.length - 1] !== activity.log) {
498
+ logLines.push(activity.log)
499
+ streamByKey.assistant = -1
500
+ }
501
+ if (phase !== 'receiving') phase = 'working'
469
502
  }
470
503
 
471
504
  const currentLabel = () => {
505
+ const elapsed = elapsedSeconds()
472
506
  if (phase === 'receiving') {
473
507
  return `Receiving response (${receivedChars} chars)`
474
508
  }
475
- if (phase === 'waiting') {
509
+ if (elapsed >= AGENT_STILL_WORKING_S) return 'Agent still working'
510
+ if (phase === 'waiting' || phase === 'working') {
476
511
  return AGENT_WAIT_STEPS[waitStepIndex % AGENT_WAIT_STEPS.length]
477
512
  }
478
513
  return earlySteps[Math.min(earlyStepIndex, earlySteps.length - 1)]
479
514
  }
480
515
 
481
516
  const formatText = () => {
482
- const elapsed = Math.floor((Date.now() - startedAt) / 1000)
483
- return `${currentLabel()}… (${elapsed}s)`
517
+ const text = `${currentLabel()}… (${elapsedSeconds()}s)`
518
+ const width = process.stderr.columns || process.stdout.columns || 80
519
+ return clipLog(text, width)
520
+ }
521
+
522
+ const reportActivity = (activity) => {
523
+ onAgentActivity(activity)
524
+ if (
525
+ (!process.stderr.isTTY || context.verbose) &&
526
+ activity.log &&
527
+ !activity.stream
528
+ ) {
529
+ process.stderr.write(`${activity.log}\n`)
530
+ }
484
531
  }
485
532
 
486
533
  if (!process.stderr.isTTY || context.verbose) {
487
534
  process.stderr.write(`${formatText()}\n`)
488
- return callback({ onAgentActivity })
535
+ const waitTimer = setInterval(() => {
536
+ if (phase === 'receiving') return
537
+ process.stderr.write(`Thinking… (${elapsedSeconds()}s)\n`)
538
+ }, AGENT_STEP_MS)
539
+ try {
540
+ return await callback({ onAgentActivity: reportActivity })
541
+ } finally {
542
+ clearInterval(waitTimer)
543
+ }
489
544
  }
490
545
 
491
546
  const columns = process.stderr.columns || process.stdout.columns || 80
492
547
  const rows = process.stderr.rows || process.stdout.rows || 24
493
- const getFrame =
494
- context.sprite === 'cat'
495
- ? (index) => getNyanFrame(index, columns)
496
- : (index) => getBananaFrame(index, columns, rows)
497
- const frameMs = context.sprite === 'cat' ? NYAN_FRAME_MS : BANANA_FRAME_MS
548
+ const logRows = Math.min(AGENT_LOG_ROWS, Math.max(3, rows - 10))
549
+ const spriteRows = Math.max(10, rows - logRows)
550
+ const sprite = {
551
+ banana: {
552
+ render: (index) => getBananaFrame(index, columns, spriteRows),
553
+ frameMs: BANANA_FRAME_MS,
554
+ },
555
+ cat: {
556
+ render: (index) => getNyanFrame(index, columns),
557
+ frameMs: NYAN_FRAME_MS,
558
+ },
559
+ pacman: {
560
+ render: (index) => getPacmanFrame(index, columns, spriteRows),
561
+ frameMs: PACMAN_FRAME_MS,
562
+ },
563
+ upyachka: {
564
+ render: (index) => getUpyachkaFrame(index, columns, spriteRows),
565
+ frameMs: UPYACHKA_FRAME_MS,
566
+ },
567
+ }[context.sprite]
498
568
  process.stderr.write('\x1b[?7l')
499
569
 
500
570
  const render = () => {
501
- logUpdateStderr(`${getFrame(frameIndex)}\n${formatText()}`)
571
+ const waiting = phase !== 'receiving'
572
+ const extra = waiting ? `Thinking… ${elapsedSeconds()}s` : ''
573
+ const logBlock = formatLogBlock(logLines, columns, logRows, extra)
574
+ logUpdateStderr(
575
+ `${sprite.render(frameIndex)}\n${formatText()}${logBlock ? `\n${logBlock}` : ''}`,
576
+ )
502
577
  frameIndex++
503
578
  }
504
579
 
505
- const frameTimer = setInterval(render, frameMs)
580
+ const frameTimer = setInterval(render, sprite.frameMs)
506
581
  const stepTimer = setInterval(() => {
507
582
  if (phase === 'early') {
508
583
  if (earlyStepIndex < earlySteps.length - 1) {
@@ -510,14 +585,14 @@ async function withAgentProgress(context, callback) {
510
585
  } else {
511
586
  phase = 'waiting'
512
587
  }
513
- } else if (phase === 'waiting') {
588
+ } else if (phase !== 'receiving') {
514
589
  waitStepIndex++
515
590
  }
516
591
  }, AGENT_STEP_MS)
517
592
 
518
593
  try {
519
594
  render()
520
- return await callback({ onAgentActivity })
595
+ return await callback({ onAgentActivity: reportActivity })
521
596
  } finally {
522
597
  clearInterval(frameTimer)
523
598
  clearInterval(stepTimer)
@@ -526,6 +601,16 @@ async function withAgentProgress(context, callback) {
526
601
  }
527
602
  }
528
603
 
604
+ function formatLogBlock(lines, columns, logRows, extra = '') {
605
+ const all = extra ? [...lines, extra] : lines
606
+ if (!all.length) return ''
607
+ const width = Math.max(20, columns || 80)
608
+ return all
609
+ .slice(-logRows)
610
+ .map((line) => clipLog(line, width))
611
+ .join('\n')
612
+ }
613
+
529
614
  function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
530
615
  const cmd = resolveAgent()
531
616
  const args = [
@@ -533,7 +618,8 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
533
618
  '--mode',
534
619
  'ask',
535
620
  '--output-format',
536
- 'text',
621
+ 'stream-json',
622
+ '--stream-partial-output',
537
623
  '--trust',
538
624
  '--workspace',
539
625
  cwd,
@@ -544,6 +630,9 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
544
630
  args.push(prompt)
545
631
 
546
632
  if (verbose) process.stderr.write(`Using: ${cmd}\n`)
633
+ if (onAgentActivity) {
634
+ onAgentActivity({ log: `Sending prompt (${prompt.length} chars)` })
635
+ }
547
636
 
548
637
  return new Promise((resolve, reject) => {
549
638
  const child = spawn(cmd, args, {
@@ -551,44 +640,240 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
551
640
  stdio: ['ignore', 'pipe', 'pipe'],
552
641
  })
553
642
 
554
- let stdout = ''
643
+ const stream = {
644
+ buffer: '',
645
+ lastAssistant: '',
646
+ raw: '',
647
+ result: '',
648
+ thinking: '',
649
+ }
555
650
  let stderr = ''
651
+ let settled = false
652
+
653
+ const stopChild = (signal = 'SIGTERM') => {
654
+ if (child.exitCode !== null || child.killed) return
655
+ child.kill(signal)
656
+ }
657
+
658
+ const finish = (error, value) => {
659
+ if (settled) return
660
+ settled = true
661
+ process.removeListener('SIGINT', onSignal)
662
+ process.removeListener('SIGTERM', onSignal)
663
+ if (error) reject(error)
664
+ else resolve(value)
665
+ }
666
+
667
+ const onSignal = () => {
668
+ stopChild('SIGTERM')
669
+ finish(new Error('Interrupted'))
670
+ }
671
+ process.once('SIGINT', onSignal)
672
+ process.once('SIGTERM', onSignal)
556
673
 
557
674
  child.stdout.on('data', (chunk) => {
558
- stdout += chunk
559
- if (onAgentActivity) onAgentActivity(stdout.length)
560
- if (verbose) process.stderr.write(chunk)
675
+ const text = String(chunk)
676
+ stream.raw += text
677
+ const activities = consumeAgentStdout(stream, text)
678
+ for (const activity of activities) {
679
+ if (onAgentActivity) onAgentActivity(activity)
680
+ }
561
681
  })
562
682
  child.stderr.on('data', (chunk) => {
563
- stderr += chunk
564
- if (verbose) process.stderr.write(chunk)
683
+ const text = String(chunk)
684
+ stderr += text
685
+ const line = lastNonEmptyLine(text)
686
+ if (line && onAgentActivity) onAgentActivity({ log: line })
687
+ if (verbose) process.stderr.write(text)
565
688
  })
566
689
 
567
- const timeout = setTimeout(() => {
568
- child.kill('SIGTERM')
569
- reject(
570
- new Error(
571
- `Cursor Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s. Check agent login / network.`,
572
- ),
573
- )
574
- }, AGENT_TIMEOUT_MS)
575
-
576
690
  child.on('error', (error) => {
577
- clearTimeout(timeout)
578
- reject(error)
691
+ finish(error)
579
692
  })
580
693
 
581
694
  child.on('close', (code) => {
582
- clearTimeout(timeout)
695
+ consumeAgentStdout(stream, '\n')
696
+ const raw = (
697
+ stream.result ||
698
+ stream.lastAssistant ||
699
+ stream.buffer ||
700
+ stream.raw
701
+ ).trim()
583
702
  if (code) {
584
- reject(new Error(formatAgentError(stderr, stdout)))
703
+ finish(new Error(formatAgentError(stderr, raw)))
704
+ return
705
+ }
706
+ if (!raw) {
707
+ finish(new Error(formatAgentError(stderr, '')))
585
708
  return
586
709
  }
587
- resolve(stdout.trim())
710
+ finish(null, raw)
588
711
  })
589
712
  })
590
713
  }
591
714
 
715
+ function consumeAgentStdout(stream, chunk) {
716
+ stream.buffer += chunk
717
+ const lines = stream.buffer.split('\n')
718
+ stream.buffer = lines.pop() || ''
719
+ const activities = []
720
+ for (const line of lines) {
721
+ const activity = applyAgentLine(stream, line)
722
+ if (activity) activities.push(activity)
723
+ }
724
+ return activities
725
+ }
726
+
727
+ function applyAgentLine(stream, line) {
728
+ const trimmed = line.trim()
729
+ if (!trimmed) return null
730
+
731
+ let event
732
+ try {
733
+ event = JSON.parse(trimmed)
734
+ } catch {
735
+ if (!stream.result) stream.result = trimmed
736
+ return {
737
+ charCount: trimmed.length,
738
+ log: trimmed,
739
+ stream: true,
740
+ }
741
+ }
742
+
743
+ if (!event || typeof event !== 'object') return null
744
+ if (event.type === 'user') return null
745
+
746
+ if (event.type === 'thinking') {
747
+ const text = extractThinkingText(event)
748
+ if (event.subtype === 'completed') {
749
+ return stream.thinking
750
+ ? {
751
+ log: `Thinking: ${stream.thinking}`,
752
+ stream: true,
753
+ streamKey: 'thinking',
754
+ }
755
+ : { log: 'Finished thinking' }
756
+ }
757
+ if (text) {
758
+ stream.thinking += text
759
+ return {
760
+ log: `Thinking: ${stream.thinking}`,
761
+ stream: true,
762
+ streamKey: 'thinking',
763
+ }
764
+ }
765
+ return { log: 'Thinking' }
766
+ }
767
+
768
+ if (event.type === 'result' && typeof event.result === 'string') {
769
+ stream.result = event.result
770
+ return { charCount: stream.result.length, log: 'Agent finished' }
771
+ }
772
+
773
+ if (event.type === 'assistant') {
774
+ if (event.model_call_id != null) return null
775
+ const thinking = extractContentText(event.message?.content, 'thinking')
776
+ if (thinking) {
777
+ stream.thinking += thinking
778
+ return {
779
+ log: `Thinking: ${stream.thinking}`,
780
+ stream: true,
781
+ streamKey: 'thinking',
782
+ }
783
+ }
784
+ const text = extractAssistantText(event)
785
+ if (!text) return null
786
+ if (typeof event.timestamp_ms === 'number') stream.lastAssistant += text
787
+ else stream.lastAssistant = text
788
+ return {
789
+ charCount: stream.lastAssistant.length,
790
+ log: stream.lastAssistant,
791
+ stream: true,
792
+ streamKey: 'assistant',
793
+ }
794
+ }
795
+
796
+ const log = describeAgentEvent(event)
797
+ if (log) return { log }
798
+ if (event.type) {
799
+ return {
800
+ log: event.subtype
801
+ ? `${event.type}: ${event.subtype}`
802
+ : String(event.type),
803
+ }
804
+ }
805
+ return null
806
+ }
807
+
808
+ function extractAssistantText(event) {
809
+ return extractContentText(event.message?.content, 'text')
810
+ }
811
+
812
+ function extractThinkingText(event) {
813
+ if (typeof event.text === 'string' && event.text) return event.text
814
+ if (typeof event.delta === 'string' && event.delta) return event.delta
815
+ return (
816
+ extractContentText(event.message?.content, 'thinking') ||
817
+ extractContentText(event.content, 'thinking') ||
818
+ extractContentText(event.message?.content, 'text')
819
+ )
820
+ }
821
+
822
+ function extractContentText(content, type) {
823
+ if (!Array.isArray(content)) return ''
824
+ return content
825
+ .filter((part) => part?.type === type && typeof part.text === 'string')
826
+ .map((part) => part.text)
827
+ .join('')
828
+ }
829
+
830
+ function describeAgentEvent(event) {
831
+ if (event.type === 'system' && event.subtype === 'init') {
832
+ return event.model ? `Agent started (${event.model})` : 'Agent started'
833
+ }
834
+ if (event.type === 'tool_call') {
835
+ return describeToolCall(event.tool_call, event.subtype === 'completed')
836
+ }
837
+ return ''
838
+ }
839
+
840
+ function describeToolCall(toolCall, done) {
841
+ if (!toolCall || typeof toolCall !== 'object') {
842
+ return done ? 'Tool done' : 'Tool call'
843
+ }
844
+ const [key, payload] = Object.entries(toolCall)[0] || []
845
+ if (key === 'function' && payload?.name) {
846
+ return done ? `Done ${payload.name}` : payload.name
847
+ }
848
+ const args =
849
+ payload?.args && typeof payload.args === 'object' ? payload.args : {}
850
+ const name = (key || 'tool').replace(/ToolCall$/, '')
851
+ const detail =
852
+ args.path ||
853
+ args.file_path ||
854
+ args.command ||
855
+ args.query ||
856
+ args.pattern ||
857
+ args.glob ||
858
+ ''
859
+ const label = detail ? `${name} ${detail}` : name
860
+ return done ? `Done ${label}` : `${label}`
861
+ }
862
+
863
+ function lastNonEmptyLine(text) {
864
+ const lines = String(text)
865
+ .split(/\r?\n/)
866
+ .map((line) => line.trim())
867
+ .filter(Boolean)
868
+ return lines.at(-1) || ''
869
+ }
870
+
871
+ function clipLog(text, max = AGENT_LOG_MAX) {
872
+ const oneLine = String(text).replace(/\s+/g, ' ').trim()
873
+ if (oneLine.length <= max) return oneLine
874
+ return `…${oneLine.slice(-(max - 1))}`
875
+ }
876
+
592
877
  function formatAgentError(stderr, stdout) {
593
878
  const text = (stderr || stdout || AGENT_MISSING).trim()
594
879
  if (/authentication required/i.test(text)) {
@@ -848,6 +1133,10 @@ async function validateAndNormalizePlans(cwd, plans) {
848
1133
  }
849
1134
  }
850
1135
 
1136
+ function isOnDisk(cwd, file) {
1137
+ return fs.existsSync(path.join(cwd, file))
1138
+ }
1139
+
851
1140
  function isTracked(cwd, file) {
852
1141
  return (
853
1142
  spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
@@ -857,16 +1146,25 @@ function isTracked(cwd, file) {
857
1146
  )
858
1147
  }
859
1148
 
1149
+ function isInHead(cwd, file) {
1150
+ return (
1151
+ spawnSync('git', ['cat-file', '-e', `HEAD:${file}`], {
1152
+ cwd: cwd,
1153
+ encoding: 'utf8',
1154
+ }).status === 0
1155
+ )
1156
+ }
1157
+
860
1158
  function isUntracked(cwd, file) {
861
- return !isTracked(cwd, file) && fs.existsSync(path.join(cwd, file))
1159
+ return !isTracked(cwd, file) && isOnDisk(cwd, file)
862
1160
  }
863
1161
 
864
1162
  function fileHasChanges(cwd, file) {
865
- const filePath = path.join(cwd, file)
1163
+ const exists = isOnDisk(cwd, file)
866
1164
  const tracked = isTracked(cwd, file)
867
1165
 
868
- if (!tracked) return fs.existsSync(filePath)
869
- if (!fs.existsSync(filePath)) return true
1166
+ if (!exists && (tracked || isInHead(cwd, file))) return true
1167
+ if (!tracked) return exists
870
1168
 
871
1169
  return (
872
1170
  spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
@@ -878,7 +1176,20 @@ function fileHasChanges(cwd, file) {
878
1176
 
879
1177
  function stageFiles(cwd, files) {
880
1178
  if (!files.length) return
881
- git(cwd, ['add', '-A', '--', ...files])
1179
+
1180
+ const existing = []
1181
+ const removed = []
1182
+ for (const file of files) {
1183
+ if (isOnDisk(cwd, file)) existing.push(file)
1184
+ else removed.push(file)
1185
+ }
1186
+
1187
+ // `git add -A -- deleted/path` warns or misses the deletion when the
1188
+ // parent directory is already gone. `git rm` stages the index removal.
1189
+ if (existing.length) git(cwd, ['add', '-A', '--', ...existing])
1190
+ if (removed.length) {
1191
+ git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...removed])
1192
+ }
882
1193
  }
883
1194
 
884
1195
  async function confirm(count) {
@@ -0,0 +1,140 @@
1
+ // High-resolution terminal sprites reconstructed from the supplied reference.
2
+ // Two vertical source pixels share one terminal cell via half-block glyphs.
3
+
4
+ const RESET = '\x1b[0m'
5
+ const FRAME_WIDTH = 80
6
+ const FRAME_HEIGHT = 16
7
+ const FRAME_COUNT = 82
8
+ const SPRITE_WIDTH = 14
9
+ const SPRITE_HEIGHT = 16
10
+ const COLORS = { B: 33, C: 51, O: 208, P: 218, R: 196, W: 231, Y: 226 }
11
+
12
+ const GHOST = [
13
+ '................',
14
+ '......XXXX......',
15
+ '....XXXXXXXX....',
16
+ '...XXXXXXXXXX...',
17
+ '..XXXXXXXXXXXX..',
18
+ '..XWWXXXXWWWXXX.',
19
+ '..WWWWXXWWWWXX..',
20
+ '..BBWWXXBBWWXX..',
21
+ '.XBBWWXXBBWWXXX.',
22
+ '.XXWWXXXXWWXXXX.',
23
+ '.XXXXXXXXXXXXXX.',
24
+ '.XXXXXXXXXXXXXX.',
25
+ '.XXXXXXXXXXXXXX.',
26
+ '.XXXXXXXXXXXXXX.',
27
+ '.XXXXXXXXXXXXXX.',
28
+ '.XX.XXX..XXX.XX.',
29
+ '.X...XX..XX...X.',
30
+ '................',
31
+ ]
32
+
33
+ const PACMAN_CLOSED = [
34
+ '................',
35
+ '.....YYYYYY.....',
36
+ '...YYYYYYYYYY...',
37
+ '..YYYYYYYYYYYY..',
38
+ '.YYYYYYYYYYYYYY.',
39
+ 'YYYYYYYYYYYYYYYY',
40
+ 'YYYYYYYYYYYYYYYY',
41
+ 'YYYYYYYYYYYYYYYY',
42
+ 'YYYYYYYYYYYYYYYY',
43
+ 'YYYYYYYYYYYYYYYY',
44
+ 'YYYYYYYYYYYYYYYY',
45
+ 'YYYYYYYYYYYYYYYY',
46
+ 'YYYYYYYYYYYYYYYY',
47
+ '.YYYYYYYYYYYYYY.',
48
+ '..YYYYYYYYYYYY..',
49
+ '...YYYYYYYYYY...',
50
+ '.....YYYYYY.....',
51
+ '................',
52
+ ]
53
+
54
+ const PACMAN_OPEN = [
55
+ '................',
56
+ '.....YYYYYY.....',
57
+ '...YYYYYYYYYY...',
58
+ '..YYYYYYYYYYYY..',
59
+ '.YYYYYYYYYYYYYY.',
60
+ 'YYYYYYYYYYYYYYYY',
61
+ '..YYYYYYYYYYYYYY',
62
+ '....YYYYYYYYYYYY',
63
+ '......YYYYYYYYYY',
64
+ '........YYYYYYYY',
65
+ '......YYYYYYYYYY',
66
+ '....YYYYYYYYYYYY',
67
+ '..YYYYYYYYYYYYYY',
68
+ '.YYYYYYYYYYYYYY.',
69
+ '..YYYYYYYYYYYY..',
70
+ '...YYYYYYYYYY...',
71
+ '.....YYYYYY.....',
72
+ '................',
73
+ ]
74
+
75
+ function drawSprite(canvas, sprite, left, color) {
76
+ const columns = pickIndexes(sprite[0].length, SPRITE_WIDTH)
77
+ const rows = pickIndexes(sprite.length, SPRITE_HEIGHT)
78
+ for (let y = 0; y < rows.length; y++) {
79
+ for (let x = 0; x < columns.length; x++) {
80
+ const cell = sprite[rows[y]][columns[x]]
81
+ const targetX = left + x
82
+ if (cell === '.' || targetX < 0 || targetX >= FRAME_WIDTH) continue
83
+ canvas[y][targetX] = cell === 'X' ? color : cell
84
+ }
85
+ }
86
+ }
87
+
88
+ function buildFrame(index) {
89
+ const canvas = Array.from({ length: FRAME_HEIGHT }, () =>
90
+ Array(FRAME_WIDTH).fill('.'),
91
+ )
92
+ const pacmanLeft = FRAME_WIDTH - 7 - index * 2
93
+ const pacman = Math.floor(index / 2) % 2 ? PACMAN_CLOSED : PACMAN_OPEN
94
+ drawSprite(canvas, pacman, pacmanLeft, 'Y')
95
+
96
+ for (const [ghostIndex, color] of ['R', 'C', 'P', 'O'].entries()) {
97
+ drawSprite(canvas, GHOST, pacmanLeft + 20 + ghostIndex * 18, color)
98
+ }
99
+ return canvas
100
+ }
101
+
102
+ function pickIndexes(srcSize, maxSize) {
103
+ if (srcSize <= maxSize) {
104
+ return Array.from({ length: srcSize }, (_, index) => index)
105
+ }
106
+ return Array.from({ length: maxSize }, (_, index) =>
107
+ Math.round((index * (srcSize - 1)) / (maxSize - 1)),
108
+ )
109
+ }
110
+
111
+ function paint(top, bottom) {
112
+ const topColor = COLORS[top]
113
+ const bottomColor = COLORS[bottom]
114
+ if (topColor === undefined && bottomColor === undefined) return ' '
115
+ if (topColor === bottomColor) return `\x1b[48;5;${topColor}m \x1b[0m`
116
+ if (topColor === undefined) return `\x1b[38;5;${bottomColor}m▄\x1b[0m`
117
+ if (bottomColor === undefined) return `\x1b[38;5;${topColor}m▀\x1b[0m`
118
+ return `\x1b[38;5;${topColor};48;5;${bottomColor}m▀\x1b[0m`
119
+ }
120
+
121
+ export function getPacmanFrame(index, terminalColumns = 80, terminalRows = 24) {
122
+ const frame = buildFrame(index % FRAME_COUNT)
123
+ const maxColumns = Math.max(16, (terminalColumns || 80) - 1)
124
+ const maxLogicalRows = Math.max(4, ((terminalRows || 24) - 2) * 2)
125
+ const columns = pickIndexes(FRAME_WIDTH, maxColumns)
126
+ const rowCount = Math.min(FRAME_HEIGHT, maxLogicalRows)
127
+ const rows = pickIndexes(FRAME_HEIGHT, rowCount - (rowCount % 2))
128
+ const lines = []
129
+
130
+ for (let index = 0; index < rows.length; index += 2) {
131
+ const top = frame[rows[index]]
132
+ const bottom = frame[rows[index + 1]]
133
+ lines.push(columns.map((x) => paint(top[x], bottom[x])).join('') + RESET)
134
+ }
135
+ return lines.join('\n')
136
+ }
137
+
138
+ export function getPacmanFrameCount() {
139
+ return FRAME_COUNT
140
+ }
@@ -0,0 +1,229 @@
1
+ // Five frames extracted from the classic upyachka GIF.
2
+ // Default-color block pixels stay visible on light and dark terminals.
3
+
4
+ const FRAME_WIDTH = 29
5
+ const FRAME_HEIGHT = 36
6
+
7
+ const FRAMES = [
8
+ [
9
+ '..................#..........',
10
+ '................#####........',
11
+ '...............#######.......',
12
+ '............#..##....##......',
13
+ '............#####....##......',
14
+ '...........######....##......',
15
+ '...........##.####.###.......',
16
+ '...........##...#####........',
17
+ '...........##....###.........',
18
+ '...........##....###.........',
19
+ '...........##...#####........',
20
+ '...........#....##.##........',
21
+ '.............#####..#........',
22
+ '..............########.......',
23
+ '...............##.####.......',
24
+ '..............##.............',
25
+ '..............##.............',
26
+ '.............######..........',
27
+ '.............##########......',
28
+ '.............##....#####.....',
29
+ '.............##.......##.....',
30
+ '.............##.......##.....',
31
+ '.............##......##......',
32
+ '.............##......##......',
33
+ '.............##......##......',
34
+ '.............##......##......',
35
+ '.............##.....##.......',
36
+ '.............##.....##.......',
37
+ '.............##.....##.......',
38
+ '.............##..............',
39
+ '.............##..............',
40
+ '.............##..............',
41
+ '.............##..............',
42
+ '.............##..............',
43
+ '.............##..............',
44
+ '..............#..............',
45
+ ],
46
+ [
47
+ '...............##............',
48
+ '.............######..........',
49
+ '............###..###.........',
50
+ '............##....##.##......',
51
+ '............##....#####......',
52
+ '............##....######.....',
53
+ '.............#######..##.....',
54
+ '..............#####...##.....',
55
+ '...............###....##.....',
56
+ '...............###....##.....',
57
+ '..............##.##...##.....',
58
+ '..............##.##....#.....',
59
+ '.............##.#####........',
60
+ '.............########........',
61
+ '.............####.##.........',
62
+ '...................##........',
63
+ '...................##........',
64
+ '...............#######.......',
65
+ '............##########.......',
66
+ '...........#####....##.......',
67
+ '...........##.......##.......',
68
+ '............##......##.......',
69
+ '............##......##.......',
70
+ '............##......##.......',
71
+ '............##......##.......',
72
+ '............##......##.......',
73
+ '.............##.....##.......',
74
+ '.............##.....##.......',
75
+ '.............##.....##.......',
76
+ '....................##.......',
77
+ '....................##.......',
78
+ '....................##.......',
79
+ '....................##.......',
80
+ '....................##.......',
81
+ '....................##.......',
82
+ '....................#........',
83
+ ],
84
+ [
85
+ '.............................',
86
+ '.............................',
87
+ '.............................',
88
+ '.............................',
89
+ '.............................',
90
+ '.............................',
91
+ '..............#..............',
92
+ '............#####............',
93
+ '...........###.###...........',
94
+ '...........##...##...........',
95
+ '..........##.....#...........',
96
+ '...........##...##...........',
97
+ '.......###############.......',
98
+ '.......###############.......',
99
+ '.......##....###....##.......',
100
+ '........##....#.....##.......',
101
+ '........##....##....#........',
102
+ '.........##...##...##........',
103
+ '.........##...##...##........',
104
+ '..........#...##...##........',
105
+ '..............##.............',
106
+ '..............##.............',
107
+ '..............##.............',
108
+ '..............##.............',
109
+ '..........###########........',
110
+ '......#######...########.....',
111
+ '.....#####..........####.....',
112
+ '.....##...............##.....',
113
+ '......##..............##.....',
114
+ '......##.............##......',
115
+ '......##.............##......',
116
+ '......##.............##......',
117
+ '.......##...........##.......',
118
+ '.......##...........##.......',
119
+ '.......##...........##.......',
120
+ '.......##....................',
121
+ ],
122
+ [
123
+ '.............................',
124
+ '............#####............',
125
+ '...........#######...........',
126
+ '###.......###...##...........',
127
+ '.####.....##....##.......####',
128
+ '...####...##....##....######.',
129
+ '.....########..##########....',
130
+ '.......###############.......',
131
+ '............#####............',
132
+ '.............##..............',
133
+ '.............##..............',
134
+ '..............#..............',
135
+ '..............#..............',
136
+ '..............#..............',
137
+ '..............#..............',
138
+ '..............##.............',
139
+ '..............##.............',
140
+ '..............##.............',
141
+ '..............##.............',
142
+ '.............###.............',
143
+ '............#####............',
144
+ '............##.###...........',
145
+ '...........##...##...........',
146
+ '...........##....##..........',
147
+ '..........##.....##..........',
148
+ '.........##.......##.........',
149
+ '.........##.......##.........',
150
+ '.........##........##........',
151
+ '........###........##........',
152
+ '........##.........##........',
153
+ '........##.........##........',
154
+ '........##.........##........',
155
+ '.......##...........##.......',
156
+ '.......##...........##.......',
157
+ '.......##...........##.......',
158
+ '.............................',
159
+ ],
160
+ [
161
+ '.............................',
162
+ '.............................',
163
+ '.............................',
164
+ '.............................',
165
+ '.............................',
166
+ '.............................',
167
+ '.............................',
168
+ '.............................',
169
+ '.............................',
170
+ '.............................',
171
+ '.............................',
172
+ '............###..............',
173
+ '...........######............',
174
+ '..........###..###...........',
175
+ '..........##....##...........',
176
+ '..........##....##...........',
177
+ '..........##...###...........',
178
+ '...........######............',
179
+ '............####.............',
180
+ '...........#####.............',
181
+ '..........#######............',
182
+ '.........#########...........',
183
+ '........##########...........',
184
+ '........###..######..........',
185
+ '.............##..##..........',
186
+ '.....###.....##......###.....',
187
+ '.....######..##...#####......',
188
+ '......#################......',
189
+ '......##...#######...##......',
190
+ '......##.....##.....##.......',
191
+ '.......##...........##.......',
192
+ '.......##..........##........',
193
+ '.......###.........##........',
194
+ '........##.........##........',
195
+ '........##.........#.........',
196
+ '.............................',
197
+ ],
198
+ ]
199
+
200
+ function pickIndexes(srcSize, maxSize) {
201
+ if (srcSize <= maxSize) {
202
+ return Array.from({ length: srcSize }, (_, index) => index)
203
+ }
204
+ return Array.from({ length: maxSize }, (_, index) =>
205
+ Math.round((index * (srcSize - 1)) / (maxSize - 1)),
206
+ )
207
+ }
208
+
209
+ export function getUpyachkaFrame(
210
+ index,
211
+ terminalColumns = 80,
212
+ terminalRows = 24,
213
+ ) {
214
+ const maxColumns = Math.max(8, Math.floor((terminalColumns - 1) / 2))
215
+ const maxRows = Math.max(8, (terminalRows || 24) - 2)
216
+ const columns = pickIndexes(FRAME_WIDTH, maxColumns)
217
+ const rows = pickIndexes(FRAME_HEIGHT, maxRows)
218
+ const frame = FRAMES[index % FRAMES.length]
219
+
220
+ return rows
221
+ .map((y) =>
222
+ columns.map((x) => (frame[y][x] === '#' ? '██' : ' ')).join(''),
223
+ )
224
+ .join('\n')
225
+ }
226
+
227
+ export function getUpyachkaFrameCount() {
228
+ return FRAMES.length
229
+ }