zames_pro 2.71.0 → 2.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import { spawn } from 'node:child_process';
2
+ /** Run the statusLine command and return its first stdout line (trimmed). */
3
+ export function runStatusLineCommand(workdir, command, info, timeoutMs = 4000) {
4
+ return new Promise((resolve) => {
5
+ let child;
6
+ try {
7
+ child = spawn(command, {
8
+ cwd: workdir,
9
+ shell: process.platform === 'win32'
10
+ ? process.env.ComSpec || true
11
+ : '/bin/sh',
12
+ windowsHide: true,
13
+ env: { ...process.env },
14
+ });
15
+ }
16
+ catch {
17
+ resolve('');
18
+ return;
19
+ }
20
+ let out = '';
21
+ let done = false;
22
+ const finish = () => {
23
+ if (done)
24
+ return;
25
+ done = true;
26
+ const first = String(out).split(/\r?\n/)[0].trim();
27
+ resolve(first);
28
+ };
29
+ const timer = setTimeout(() => {
30
+ try {
31
+ child.kill();
32
+ }
33
+ catch { }
34
+ finish();
35
+ }, timeoutMs);
36
+ child.stdout?.on('data', (c) => {
37
+ out += c.toString();
38
+ });
39
+ child.on('error', () => {
40
+ clearTimeout(timer);
41
+ finish();
42
+ });
43
+ child.on('close', () => {
44
+ clearTimeout(timer);
45
+ finish();
46
+ });
47
+ if (child.stdin) {
48
+ child.stdin.on('error', () => { });
49
+ child.stdin.end(JSON.stringify(info) + String.fromCharCode(10));
50
+ }
51
+ });
52
+ }
package/dist/subagent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadHooks, runLifecycleHooks } from './hooks.js';
1
2
  // Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
2
3
  // by the agent loop as a SEAM (like onAutoCompact): it calls back into the
3
4
  // caller, which owns the whole lifecycle. This module builds that callback.
@@ -13,6 +14,19 @@
13
14
  // browser.ts, which owns the live Playwright context) — out of scope here.
14
15
  /** Hard cap on iterations for one subagent run. */
15
16
  export const SUBAGENT_MAX_ITER = 30;
17
+ // The report is fed back into the PARENT chat as a tool result, so a runaway
18
+ // subagent must not be able to blow up the parent's context with one answer.
19
+ // The tail is trimmed with a marker, not silently dropped.
20
+ export const SUBAGENT_REPORT_CAP = 20_000;
21
+ /** Cap a subagent report before it becomes a parent-context tool result. */
22
+ export function capReport(text, cap = SUBAGENT_REPORT_CAP) {
23
+ if (text.length <= cap)
24
+ return text;
25
+ return (text.slice(0, cap) +
26
+ '\n… [report truncated: ' +
27
+ (text.length - cap) +
28
+ ' chars omitted]');
29
+ }
16
30
  /**
17
31
  * The subagent persona, prepended to the task message. It is deliberately NOT
18
32
  * injected as a replacement system prompt: the nested runAgentLoop builds the
@@ -43,7 +57,7 @@ export function subagentPersona(type) {
43
57
  * returns only the report.
44
58
  */
45
59
  export function createSubagentRunner(opts) {
46
- const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, selfImprovement = false, onAskPermission, } = opts;
60
+ const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, onAskPermission, } = opts;
47
61
  let used = 0;
48
62
  return async (req) => {
49
63
  if (used >= maxSubagents) {
@@ -84,11 +98,24 @@ export function createSubagentRunner(opts) {
84
98
  locale,
85
99
  askDeadlineMs,
86
100
  maxAfterToolRetries,
87
- selfImprovement,
101
+ // A subagent NEVER touches BACKLOG.md, even in the operator's dev
102
+ // mode: its sub-task is scoped, and a general subagent appending
103
+ // "improvement" notes would be unrelated, unsupervised edits.
104
+ selfImprovement: false,
88
105
  // No recursion: the subagent cannot spawn its own subagents.
89
106
  onSubagent: null,
90
107
  // Do not persist the subagent chat as a session.
91
108
  onChatReady: () => { },
109
+ // Keep the PARENT's UI alive during the nested run. The nested loop
110
+ // OVERWRITES browser.onSendStart/… with its own callbacks; without
111
+ // forwarding the parent's hooks the spinner and the throttle countdown
112
+ // would go blank for the whole subagent run (a long subagent looked
113
+ // like a frozen, dead agent). The nested loop, not this runner, owns
114
+ // the assignment, so we hand it the functions it should install.
115
+ onThinking: savedHooks.onSendStart ?? undefined,
116
+ onSendPause: savedHooks.onSendPause ?? undefined,
117
+ onSendState: savedHooks.onSendState ?? undefined,
118
+ onNotice: savedHooks.onNotice ?? undefined,
92
119
  onAskPermission: onAskPermission
93
120
  ? (info) => onAskPermission({
94
121
  tool: info.tool,
@@ -112,8 +139,18 @@ export function createSubagentRunner(opts) {
112
139
  browser.onSendState = savedHooks.onSendState;
113
140
  browser.onNotice = savedHooks.onNotice;
114
141
  }
142
+ // SubagentStop hook (N32): fires after the nested loop finishes, so a hook
143
+ // can clean up or notify. Best-effort; its stdout is appended to the report.
144
+ let hookOut = '';
145
+ try {
146
+ hookOut = await runLifecycleHooks(loadHooks(workdir), 'SubagentStop', workdir, { type: req.type, ok: failed ? 'false' : 'true' });
147
+ }
148
+ catch { }
115
149
  if (failed)
116
150
  return { ok: false, text: failed };
117
- return { ok: true, text: report };
151
+ return {
152
+ ok: true,
153
+ text: capReport(hookOut ? report + '\n\n' + hookOut : report),
154
+ };
118
155
  };
119
156
  }
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { assertCommandInsideRoot, runFile, runShell } from './shell.js';
3
+ import { assertCommandInsideRoot, runFile, runShell, startBackground, pollBackground, killBackground, } from './shell.js';
4
+ import { isImageName, guessMime, formatSize } from './attachments.js';
4
5
  import { safePath } from './sandbox.js';
5
6
  import { createGitTools } from './gitTools.js';
6
7
  import { createWebTools } from './web.js';
@@ -26,6 +27,17 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
26
27
  // that omits `path` (or sends it as null) becomes String(undefined) ===
27
28
  // "undefined" and the tool silently creates a file literally named
28
29
  // "undefined" in the working directory. `req()` rejects that up front.
30
+ // A NUL byte is the classic binary signal (text files essentially never
31
+ // contain one). We only scan the head: a huge archive must not be walked
32
+ // byte-by-byte just to decide it is not text.
33
+ const isBinaryBuffer = (buf) => {
34
+ const n = Math.min(buf.length, 8000);
35
+ for (let i = 0; i < n; i++) {
36
+ if (buf[i] === 0)
37
+ return true;
38
+ }
39
+ return false;
40
+ };
29
41
  const req = (v, name) => {
30
42
  if (v === undefined || v === null) {
31
43
  throw new Error(`Missing required argument: ${name}`);
@@ -42,7 +54,10 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
42
54
  description: 'Read a file. Optional: offset and limit (lines). ' +
43
55
  'numbered=true adds cat -n style line numbers (1-based) for reference ' +
44
56
  'only — do NOT copy the numbers into Edit old_string. ' +
45
- 'Lines longer than 2000 chars are truncated.',
57
+ 'Lines longer than 2000 chars are truncated. ' +
58
+ 'Binary files (images, archives) are not decoded: an image returns its ' +
59
+ 'path/size as a marker (the operator can paste it to attach it), and ' +
60
+ 'other binaries return a short hex header instead of mojibake.',
46
61
  parameters: {
47
62
  path: 'string',
48
63
  offset: 'number?',
@@ -51,7 +66,34 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
51
66
  },
52
67
  fn: async ({ path: p, offset, limit, numbered }) => {
53
68
  const file = safe(req(p, 'path'));
54
- const content = await fs.readFile(file, 'utf-8');
69
+ // Binary guard (N35): reading an image/archive as utf-8 produced
70
+ // mojibake the model tried to reason about. Detect a binary buffer
71
+ // BEFORE decoding and answer with useful metadata instead.
72
+ const buf = await fs.readFile(file);
73
+ if (isImageName(file)) {
74
+ return ('[image: ' +
75
+ p +
76
+ '] (' +
77
+ guessMime(file) +
78
+ ', ' +
79
+ formatSize(buf.length) +
80
+ ') — this is an image; its pixels cannot be read as text. To have ' +
81
+ 'the model SEE it, the operator can paste/drag the file into the ' +
82
+ 'chat (it becomes an [image#N] attachment).');
83
+ }
84
+ if (isBinaryBuffer(buf)) {
85
+ const head = buf.subarray(0, 16);
86
+ return ('(binary file: ' +
87
+ p +
88
+ ', ' +
89
+ formatSize(buf.length) +
90
+ ', ' +
91
+ guessMime(file) +
92
+ ')\nFirst bytes (hex): ' +
93
+ Buffer.from(head).toString('hex') +
94
+ '\nUse Bash (e.g. `file`, `xxd`, `unzip -l`) to inspect it.');
95
+ }
96
+ const content = buf.toString('utf-8');
55
97
  if (content === '')
56
98
  return '(file is empty)';
57
99
  const lines = content.split('\n');
@@ -126,13 +168,43 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
126
168
  {
127
169
  name: 'Bash',
128
170
  description: 'Run a shell command in the working directory (cmd.exe on Windows, sh on Linux/macOS). ' +
129
- 'Do not use for long-running processes (servers) — they will hit the timeout. ' +
130
171
  'Do not use for commands that require interactive input. ' +
172
+ 'For a long-running process (dev server, watch, long build) set ' +
173
+ 'run_in_background=true: it starts the command and returns an id ' +
174
+ 'immediately; poll its output with BashOutput and stop it with BashOutput kill. ' +
131
175
  'For git use the Git* tools. For the web use WebFetch / WebSearch.',
132
- parameters: { command: 'string', timeout: 'number?' },
133
- fn: async ({ command, timeout }, ctx) => {
134
- assertCommandInsideRoot(root, req(command, 'command'));
135
- return runShell(workdir, req(command, 'command'), timeout, ctx?.signal);
176
+ parameters: {
177
+ command: 'string',
178
+ timeout: 'number?',
179
+ run_in_background: 'boolean?',
180
+ },
181
+ fn: async ({ command, timeout, run_in_background }, ctx) => {
182
+ const cmd = req(command, 'command');
183
+ assertCommandInsideRoot(root, cmd);
184
+ if (run_in_background) {
185
+ const id = startBackground(workdir, cmd);
186
+ return ('Started in background with id ' +
187
+ id +
188
+ '. Poll it with BashOutput (id="' +
189
+ id +
190
+ '") and stop it with BashOutput (id="' +
191
+ id +
192
+ '", kill=true).');
193
+ }
194
+ return runShell(workdir, cmd, timeout, ctx?.signal);
195
+ },
196
+ },
197
+ {
198
+ name: 'BashOutput',
199
+ description: 'Read the accumulated stdout+stderr of a process started with Bash ' +
200
+ 'run_in_background=true, and optionally kill it. id — the id returned ' +
201
+ 'by Bash. kill=true terminates the process (and its children).',
202
+ parameters: { id: 'string', kill: 'boolean?' },
203
+ fn: async ({ id, kill }) => {
204
+ const pid = req(id, 'id');
205
+ if (kill)
206
+ return killBackground(pid);
207
+ return pollBackground(pid);
136
208
  },
137
209
  },
138
210
  {
@@ -243,6 +315,7 @@ export const MUTATING_TOOLS = new Set([
243
315
  'MultiEdit',
244
316
  'ApplyPatch',
245
317
  'Bash',
318
+ 'BashOutput',
246
319
  'GitAdd',
247
320
  'GitCommit',
248
321
  'GitPush',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.71.0",
3
+ "version": "2.72.1",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,21 +17,23 @@
17
17
  "scripts": {
18
18
  "start": "node ./dist/index.js",
19
19
  "dev": "ZAMES_NET_DEBUG=1 tsx ./src/index.ts --dev --headed",
20
- "clean": "node scripts/clean-dist.mjs",
21
- "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
22
- "coverage:gate": "node scripts/coverage-gate.mjs",
20
+ "clean": "tsx scripts/clean-dist.mts",
21
+ "build": "tsx scripts/clean-dist.mts && tsc -p tsconfig.build.json",
22
+ "coverage:gate": "tsx scripts/coverage-gate.mts",
23
23
  "changelog:draft": "tsx scripts/changelog-draft.mts",
24
+ "changelog:sync": "tsx scripts/changelog-draft.mts --write",
25
+ "changelog:check": "tsx scripts/changelog-draft.mts --check",
24
26
  "release": "tsx scripts/release.mts",
25
27
  "typecheck": "tsc --noEmit",
26
- "lint": "node scripts/eslint-ts6.mjs",
27
- "lint:fix": "node scripts/eslint-ts6.mjs --fix",
28
+ "lint": "tsx scripts/eslint-ts6.mts",
29
+ "lint:fix": "tsx scripts/eslint-ts6.mts --fix",
28
30
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"*.{js,json,md}\"",
29
31
  "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
30
32
  "prepublishOnly": "npm run build:readme && npm run build",
31
33
  "test": "tsx --test test/*.test.ts",
32
34
  "test:watch": "tsx --test --watch test/*.test.ts",
33
- "self-smoke": "tsx scripts/self-smoke.mjs",
34
- "render-demo": "node scripts/render-demo.mjs",
35
+ "self-smoke": "tsx scripts/self-smoke.mts",
36
+ "render-demo": "tsx scripts/render-demo.mts",
35
37
  "build:readme": "tsx scripts/build-readme.mts",
36
38
  "postinstall": "node scripts/postinstall.mjs",
37
39
  "prepare": "husky"
@@ -1,8 +1,12 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
2
5
  import {
3
6
  parseConventionalCommit,
4
7
  groupCommits,
5
8
  renderChangelogDraft,
9
+ syncUnreleased,
6
10
  } from '../src/changelog.js'
7
11
 
8
12
  // Draft a CHANGELOG section from conventional commits since a ref (BACKLOG
@@ -13,6 +17,8 @@ import {
13
17
  // Usage:
14
18
  // npm run changelog:draft # since the latest tag, or an explicit ref
15
19
  // npm run changelog:draft -- v2.63.0
20
+ // npm run changelog:draft -- --write # update the Unreleased section in place
21
+ // npm run changelog:draft -- --check # exit 1 if Unreleased is out of date
16
22
 
17
23
  function git(args: string[]): string {
18
24
  const r = spawnSync('git', args, { encoding: 'utf-8' })
@@ -20,7 +26,10 @@ function git(args: string[]): string {
20
26
  return (r.stdout || '').toString()
21
27
  }
22
28
 
23
- const argRef = process.argv.slice(2).find((a) => !a.startsWith('-'))
29
+ const argv = process.argv.slice(2)
30
+ const write = argv.includes('--write')
31
+ const check = argv.includes('--check')
32
+ const argRef = argv.find((a) => !a.startsWith('-'))
24
33
 
25
34
  // Resolve the start ref: an explicit arg, otherwise the latest tag,
26
35
  // otherwise the root commit.
@@ -46,6 +55,34 @@ const parsed = subjects
46
55
  .filter((c): c is NonNullable<typeof c> => c !== null)
47
56
  const body = renderChangelogDraft(groupCommits(parsed))
48
57
 
58
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
59
+ const changelogPath = path.join(root, 'CHANGELOG.md')
60
+
61
+ // D2: sync the `## [Unreleased]` section in CHANGELOG.md from the conventional
62
+ // commits since the last tag. `--write` edits the file; `--check` fails when
63
+ // the committed section is out of date (for CI); neither just prints the body.
64
+ if (write || check) {
65
+ const onDisk = fs.readFileSync(changelogPath, 'utf-8')
66
+ const nextText = syncUnreleased(onDisk, body)
67
+ if (write) {
68
+ if (nextText !== onDisk) {
69
+ fs.writeFileSync(changelogPath, nextText)
70
+ console.log('changelog: updated the Unreleased section')
71
+ } else {
72
+ console.log('changelog: Unreleased already up to date')
73
+ }
74
+ process.exit(0)
75
+ }
76
+ if (nextText !== onDisk) {
77
+ console.error(
78
+ 'changelog: the Unreleased section is out of date — run `npm run changelog:sync`',
79
+ )
80
+ process.exit(1)
81
+ }
82
+ console.log('changelog: Unreleased is up to date')
83
+ process.exit(0)
84
+ }
85
+
49
86
  if (!body) {
50
87
  console.error('no conventional commits since ' + ref)
51
88
  process.exit(0)
@@ -11,6 +11,12 @@ import Module from 'module'
11
11
  import path from 'path'
12
12
  import { fileURLToPath } from 'url'
13
13
 
14
+ // Module._resolveFilename is a private Node field (not in @types/node), so it
15
+ // is reached through a small structural type instead of `any`.
16
+ type ModuleWithResolve = typeof Module & {
17
+ _resolveFilename: (request: string, ...rest: unknown[]) => string
18
+ }
19
+
14
20
  const require = createRequire(import.meta.url)
15
21
  const here = path.dirname(fileURLToPath(import.meta.url))
16
22
  const ts6 = path.join(here, '..', 'node_modules', 'typescript-6')
@@ -26,16 +32,17 @@ try {
26
32
 
27
33
  // Patch Module._resolveFilename so any require('typescript') resolves to the
28
34
  // TS 6 package instead of the TS 7 the project depends on.
29
- const origResolve = Module._resolveFilename
30
- Module._resolveFilename = function (request, ...rest) {
35
+ const M = Module as ModuleWithResolve
36
+ const origResolve = M._resolveFilename
37
+ M._resolveFilename = (request: string, ...rest: unknown[]) => {
31
38
  if (request === 'typescript') {
32
39
  try {
33
40
  return require.resolve(path.join(ts6, 'lib', 'typescript.js'))
34
41
  } catch {
35
- return origResolve.call(this, request, ...rest)
42
+ return origResolve(request, ...rest)
36
43
  }
37
44
  }
38
- return origResolve.call(this, request, ...rest)
45
+ return origResolve(request, ...rest)
39
46
  }
40
47
 
41
48
  const { ESLint } = await import('eslint')
@@ -23,7 +23,9 @@ import {
23
23
  // npm run release patch --dry-run # print the plan, change nothing
24
24
  //
25
25
  // Run via tsx so it can reuse src/changelog.ts (the conventional-commit
26
- // parser) — a plain .mjs cannot import the TypeScript sources.
26
+ // parser) — a plain .mjs cannot import the TypeScript sources. The other
27
+ // helper scripts are .mts for the same reason; postinstall stays .mjs because
28
+ // it runs before tsx is guaranteed to be installed.
27
29
 
28
30
  const NL = String.fromCharCode(10)
29
31
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -43,7 +45,11 @@ function fail(msg: string): never {
43
45
  }
44
46
 
45
47
  function run(cmd: string, args: string[]): void {
46
- const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' })
48
+ const r = spawnSync(cmd, args, {
49
+ cwd: root,
50
+ stdio: 'inherit',
51
+ shell: process.platform === 'win32',
52
+ })
47
53
  if (r.status !== 0) fail(cmd + ' ' + args.join(' ') + ' exited ' + r.status)
48
54
  }
49
55
 
@@ -100,8 +106,16 @@ if (git(['tag', '--list', tag]).trim()) {
100
106
  // last tag. English commit subjects become the section body; the operator can
101
107
  // still reword the section afterwards.
102
108
  const latestTag = git(['describe', '--tags', '--abbrev=0'], true).trim()
103
- const ref = latestTag || git(['rev-list', '--max-parents=0', 'HEAD']).trim().split(NL).pop() || 'HEAD'
104
- const subjects = git(['log', '--no-merges', '--pretty=format:%s', ref + '..HEAD'])
109
+ const ref =
110
+ latestTag ||
111
+ git(['rev-list', '--max-parents=0', 'HEAD']).trim().split(NL).pop() ||
112
+ 'HEAD'
113
+ const subjects = git([
114
+ 'log',
115
+ '--no-merges',
116
+ '--pretty=format:%s',
117
+ ref + '..HEAD',
118
+ ])
105
119
  .split(NL)
106
120
  .map((s) => s.trim())
107
121
  .filter(Boolean)
@@ -129,7 +143,14 @@ const sectionBody = body
129
143
  .join(NL)
130
144
  const date = new Date().toISOString().slice(0, 10)
131
145
  const section =
132
- '## [' + next + '] - ' + date + NL + NL + (sectionBody || '### Changed' + NL + NL + '- Internal improvements.') + NL
146
+ '## [' +
147
+ next +
148
+ '] - ' +
149
+ date +
150
+ NL +
151
+ NL +
152
+ (sectionBody || '### Changed' + NL + NL + '- Internal improvements.') +
153
+ NL
133
154
 
134
155
  if (dryRun) {
135
156
  console.log('release(dry-run): ' + pkg.version + ' -> ' + next)
@@ -148,14 +169,22 @@ if (idx < 0) fail('CHANGELOG.md has no "' + anchor + '" heading')
148
169
  const afterAnchor = idx + anchor.length
149
170
  const updated =
150
171
  changelog.slice(0, afterAnchor) +
151
- NL + NL + section +
172
+ NL +
173
+ NL +
174
+ section +
152
175
  changelog.slice(afterAnchor).replace(/^\n+/, NL)
153
176
  fs.writeFileSync(changelogPath, updated)
154
177
 
155
178
  // 6. Bump package.json, preserving its exact formatting (single-line replace
156
179
  // of the version value only).
157
180
  const pkgText = fs.readFileSync(pkgPath, 'utf-8')
158
- fs.writeFileSync(pkgPath, pkgText.replace('"version": "' + pkg.version + '"', '"version": "' + next + '"'))
181
+ fs.writeFileSync(
182
+ pkgPath,
183
+ pkgText.replace(
184
+ '"version": "' + pkg.version + '"',
185
+ '"version": "' + next + '"',
186
+ ),
187
+ )
159
188
 
160
189
  // 7. Commit + tag. The push is intentionally NOT done here.
161
190
  git(['add', 'package.json', 'CHANGELOG.md'])
@@ -7,7 +7,7 @@
7
7
  // theme palette (src/theme.ts) with the DejaVu Sans Mono font that the
8
8
  // container actually ships, then screenshots it with headless Chromium.
9
9
  //
10
- // Run: node scripts/render-demo.mjs
10
+ // Run: npm run render-demo (tsx scripts/render-demo.mts)
11
11
  //
12
12
  // Isolation: chromium.launch() uses a THROWAWAY profile, never ~/.zames/profile
13
13
  // (see AGENTS.md — never touch the live agent's browser/profile).
@@ -33,9 +33,9 @@ const C = {
33
33
  toggleOn: '#6fd0b0',
34
34
  }
35
35
 
36
- const esc = (s) =>
36
+ const esc = (s: unknown): string =>
37
37
  String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
38
- const span = (color, text, bold = false) =>
38
+ const span = (color: string, text: string, bold = false): string =>
39
39
  '<span style="color:' +
40
40
  color +
41
41
  (bold ? ';font-weight:700' : '') +
@@ -44,13 +44,18 @@ const span = (color, text, bold = false) =>
44
44
  '</span>'
45
45
 
46
46
  // One rendered session, line by line (mirrors the real banner + answer flow).
47
- const lines = []
48
- const push = (html) => lines.push('<div class="l">' + html + '</div>')
47
+ const lines: string[] = []
48
+ const push = (html: string): void => {
49
+ lines.push('<div class="l">' + html + '</div>')
50
+ }
49
51
  const blank = () => push('&nbsp;')
50
52
 
51
53
  push(span(C.system, 'Working directory: ') + span(C.dir, '/home/dev/zames_pro'))
52
54
  push(span(C.system, 'Version: ') + span(C.user, '2.64.1'))
53
- push(span(C.system, 'Transcript: ') + span(C.dim, '~/.zames/logs/zames_pro-2026-10-07.log'))
55
+ push(
56
+ span(C.system, 'Transcript: ') +
57
+ span(C.dim, '~/.zames/logs/zames_pro-2026-10-07.log'),
58
+ )
54
59
  blank()
55
60
  push(
56
61
  span(
@@ -78,11 +83,7 @@ push(
78
83
  span(C.user, 'Show the version from package.json.'),
79
84
  )
80
85
  blank()
81
- push(
82
- span(C.tool, '🔧 Read') +
83
- ' ' +
84
- span(C.dim, '{"path":"package.json"}'),
85
- )
86
+ push(span(C.tool, '🔧 Read') + ' ' + span(C.dim, '{"path":"package.json"}'))
86
87
  push(
87
88
  span(C.toolResult, ' → ') +
88
89
  span(
@@ -48,8 +48,13 @@ const { createSubagentRunner } = await import('../src/subagent.ts')
48
48
  const { loadConfig } = await import('../src/config.ts')
49
49
  const { Transcript } = await import('../src/transcript.ts')
50
50
 
51
- const results = []
52
- const record = (name, ok, note) => {
51
+ interface SmokeResult {
52
+ name: string
53
+ ok: boolean
54
+ note?: string
55
+ }
56
+ const results: SmokeResult[] = []
57
+ const record = (name: string, ok: boolean, note?: string): void => {
53
58
  results.push({ name, ok, note })
54
59
  console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
55
60
  }
@@ -149,7 +154,11 @@ async function main() {
149
154
  askDeadlineMs: cfg.browser.askDeadlineMs,
150
155
  maxAfterToolRetries: cfg.browser.maxAfterToolRetries,
151
156
  })
152
- record('follow-up answered', outC.trim().length > 0, JSON.stringify(outC.slice(0, 80)))
157
+ record(
158
+ 'follow-up answered',
159
+ outC.trim().length > 0,
160
+ JSON.stringify(outC.slice(0, 80)),
161
+ )
153
162
  const onSubagent = createSubagentRunner({
154
163
  browser,
155
164
  workdir: WORK,
@@ -193,13 +202,41 @@ async function main() {
193
202
  /HELLO_SMOKE/i.test(outD),
194
203
  JSON.stringify(outD.slice(0, 120)),
195
204
  )
205
+
206
+ // Scenario E — background Bash + BashOutput (N33): start a process that
207
+ // keeps running, poll its output, then kill it.
208
+ const outE = await runAgentLoop({
209
+ browser,
210
+ tools,
211
+ task:
212
+ 'Do exactly this, then report: (1) Start the Bash command ' +
213
+ '`echo BG_MARKER_7 && sleep 120` in the BACKGROUND (run_in_background=true). ' +
214
+ '(2) Poll it once with BashOutput. (3) Kill it with BashOutput kill=true. ' +
215
+ 'Then call respond with one short line containing BG_MARKER_7.',
216
+ workdir: WORK,
217
+ maxIterations: 20,
218
+ freshChat: true,
219
+ sendSystemPrompt: true,
220
+ transcript,
221
+ onToolCall: (n) => console.log(' tool: ' + n),
222
+ onAssistantMessage: (m) => console.log(' answer: ' + m),
223
+ onWarning: (m) => console.log(' warn: ' + m),
224
+ locale: 'ru',
225
+ askDeadlineMs: cfg.browser.askDeadlineMs,
226
+ maxAfterToolRetries: cfg.browser.maxAfterToolRetries,
227
+ })
228
+ record(
229
+ 'background bash: started, polled and killed',
230
+ /BG_MARKER_7/.test(outE),
231
+ JSON.stringify(outE.slice(0, 120)),
232
+ )
196
233
  }
197
234
 
198
235
  let failed = false
199
236
  try {
200
237
  await main()
201
238
  } catch (e) {
202
- record('run', false, e.message)
239
+ record('run', false, (e as Error).message)
203
240
  failed = true
204
241
  } finally {
205
242
  await browser.close().catch(() => {})
File without changes