Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "lua-agent-builder",
"source": "./plugins/lua-agent-builder",
"description": "Build, test, and deploy Lua AI agents from inside Claude Code",
"version": "1.17.1",
"version": "1.17.2",
"homepage": "https://github.com/lua-ai-global/claude-code-lua-plugin#readme",
"repository": "https://github.com/lua-ai-global/claude-code-lua-plugin.git",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion plugins/lua-agent-builder/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lua-agent-builder",
"version": "1.17.1",
"version": "1.17.2",
"description": "Build, test, and deploy Lua AI agents from inside Claude Code",
"author": {
"name": "Lua AI",
Expand Down
6 changes: 6 additions & 0 deletions plugins/lua-agent-builder/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the `lua-agent-builder` plugin. Versions follow the tag `release-prod.yml` cuts from `package.json` (`v<version>`). lua-cli is a TypeScript SDK/CLI; it is unrelated to the Lua programming language.

## 1.17.2 — 2026-10-29

### Fixed

- `post-deploy-smoke`: the check after a deploy or promote said "agent did not respond (exit=2)" every time the command ran in an agent folder other than the one Claude Code started in (`cd agents/shop && lua version promote 3`), although the agent answered. Claude Code runs a hook from its own directory, so the `lua chat` ping and the `lua logs` scan ran outside the project and lua-cli refused them (`No lua.skill.yaml found`). Both now run in the project the deploy ran in: the shell's directory (`cwd` in the hook input), moved by any literal `cd` before the lua command (`luaWorkingDirs` in `lib/tokenizer.mjs`), then up to the nearest `lua.skill.yaml`. When it cannot tell (a computed `cd`, no project there or above) the check says it was skipped instead of blaming the agent. A failed ping now names lua-cli's own reason, and a ping that times out says the agent did not answer within 20 s. `spawnLua` takes a `cwd`.

## 1.17.1 — 2026-10-29

### Fixed (session mode only)
Expand Down
57 changes: 52 additions & 5 deletions plugins/lua-agent-builder/hooks/post-deploy-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,42 @@
// Registered for every Bash call with no `if` glob; non-matching commands
// return null at once.

import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { runHook, checkNodeVersion, isMainScript } from '../lib/hook-runtime.mjs';
import { classifyProductionCommand, SMOKE_LABELS } from '../lib/tokenizer.mjs';
import { classifyProductionCommand, luaWorkingDirs, SMOKE_LABELS } from '../lib/tokenizer.mjs';
import { isHeadless } from '../lib/headless.mjs';
import { isBuildSession } from '../lib/session-mode.mjs';

/**
* The agent project the deploy ran in: the shell's directory (`input.cwd`; the hook process's own directory is Claude
* Code's), moved by any literal `cd` before the lua command, then up to the nearest `lua.skill.yaml`. `lua chat` and
* `lua logs` read the agent from there; run anywhere else they exit 2 (`No lua.skill.yaml found`).
*
* @param {string} command
* @param {string} cwd
* @param {string} [home] — what `~` means (the user's home folder)
* @returns {string|null}
*/
export function deployProjectDir(command, cwd, home = homedir()) {
const cds = luaWorkingDirs(command);
if (!cds) return null;
let dir = cwd;
for (const d of cds) dir = resolve(dir, d === '~' || d.startsWith('~/') ? join(home, d.slice(1)) : d);
for (;;) {
if (existsSync(join(dir, 'lua.skill.yaml'))) return dir;
const up = dirname(dir);
if (up === dir) return null;
dir = up;
}
}

/** The CLI's own first error line (`✖ usage: …`), so a failed ping says why instead of guessing. */
function firstLine(text) {
return text.split('\n').map((l) => l.trim()).find(Boolean)?.slice(0, 200) ?? '';
}

/** How far back the post-deploy log scan looks, in both spellings. */
const SMOKE_WINDOW = '1m';
const SMOKE_WINDOW_MS = 60_000;
Expand Down Expand Up @@ -56,13 +87,29 @@ export async function decide(
spawnLuaFn = (await import('../lib/lua-cli.mjs')).spawnLua;
}

const cwd = deployProjectDir(command, input?.cwd ?? process.cwd());
if (!cwd) {
return {
warn: `⚠ Post-deploy smoke test (${classified.label}) skipped: could not tell which agent project the command ran in (no lua.skill.yaml there or above). Check the agent with \`lua chat -e production -m ping -t\` from its project folder.`,
};
}

// Step 1: agent responsiveness check on a dedicated per-deploy thread so
// the smoke ping doesn't pollute the agent's default production thread.
const pingThread = `lua-plugin-smoke-${Date.now()}`;
const ping = await spawnLuaFn(['chat', '--ci', '-e', 'production', '-m', 'ping', '-t', pingThread], { timeoutMs: 20_000 });
const ping = await spawnLuaFn(['chat', '--ci', '-e', 'production', '-m', 'ping', '-t', pingThread], {
timeoutMs: 20_000,
cwd,
});
if (ping.timedOut) {
return {
warn: `⚠ Post-deploy smoke test (${classified.label}): agent did not answer within 20 s. Check production logs.`,
};
}
if (ping.exitCode !== 0) {
const why = firstLine(ping.stderr);
return {
warn: `⚠ Post-deploy smoke test (${classified.label}): agent did not respond (exit=${ping.exitCode}). Check production logs.`,
warn: `⚠ Post-deploy smoke test (${classified.label}): the ping to the agent failed (exit=${ping.exitCode})${why ? `: ${why}` : ''}. Check production logs.`,
};
}

Expand Down Expand Up @@ -90,11 +137,11 @@ export async function decide(
let windowedByServer = true;
let logs = await spawnLuaFn(
['logs', '--ci', '--type', 'all', '--since', SMOKE_WINDOW, '--limit', '20', '--json'],
{ timeoutMs: 10_000 }
{ timeoutMs: 10_000, cwd }
);
if (logs.exitCode !== 0) {
windowedByServer = false;
logs = await spawnLuaFn(['logs', '--ci', '--type', 'all', '--limit', '20', '--json'], { timeoutMs: 10_000 });
logs = await spawnLuaFn(['logs', '--ci', '--type', 'all', '--limit', '20', '--json'], { timeoutMs: 10_000, cwd });
}
if (logs.exitCode !== 0) return null;

Expand Down
3 changes: 2 additions & 1 deletion plugins/lua-agent-builder/lib/lua-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ const MAX_OUTPUT_BYTES = 1_048_576; // 1 MB per stream
* @param {{timeoutMs?: number, env?: Record<string, string>}} [opts]
* @returns {Promise<{exitCode: number|null, stdout: string, stderr: string, timedOut: boolean, probe?: object, classification?: 'STUCK_COMMAND'|'BROKEN_INSTALL'}>}
*/
export async function spawnLua(args, { timeoutMs = DEFAULT_TIMEOUT_MS, env = {} } = {}) {
export async function spawnLua(args, { timeoutMs = DEFAULT_TIMEOUT_MS, env = {}, cwd } = {}) {
const child = spawn('lua', args, {
...(cwd ? { cwd } : {}),
env: { ...process.env, ...env },
shell: false,
windowsHide: true,
Expand Down
23 changes: 23 additions & 0 deletions plugins/lua-agent-builder/lib/tokenizer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,29 @@ export function findLuaInvocations(command, { budgetMs = TIME_BUDGET_MS } = {})
return out;
}

/**
* The literal `cd` targets a command runs, in order, before its first lua invocation (`cd agents/x && lua deploy`):
* where that lua command runs, relative to the shell's directory. `[]` = no `cd`; `null` = a `cd` whose target is
* computed or `-`, so the directory is unknown. Top-level segments only.
*
* @param {unknown} command
* @returns {string[]|null}
*/
export function luaWorkingDirs(command) {
if (typeof command !== 'string' || command.length > MAX_COMMAND_LENGTH) return null;
const dirs = [];
for (const seg of lex(command).segments) {
const words = seg.words;
if ([...commandPositions(words)].some((j) => invocationAt(words, j, j > 0))) return dirs;
if (nameOf(words[0] ?? { text: '', dynamic: true }) !== 'cd') continue;
const args = words.slice(1).filter((w) => w.text !== '--');
if (args.length === 0) dirs.push('~');
else if (args.length === 1 && !args[0].dynamic && args[0].text !== '-') dirs.push(args[0].text);
else return null;
}
return dirs;
}

/**
* True iff the command runs a gated production verb and every such verb
* carries the LUA_DEPLOY_CONFIRMED=1 prefix in its canonical shape.
Expand Down
6 changes: 3 additions & 3 deletions plugins/lua-agent-builder/mcp/lua-platform/dist/server.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions plugins/lua-agent-builder/mcp/lua-platform/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/lua-agent-builder/mcp/lua-platform/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lua/claude-plugin-mcp",
"version": "1.17.1",
"version": "1.17.2",
"description": "Read-only MCP server for the lua-agent-builder Claude Code plugin",
"type": "module",
"main": "dist/server.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function apiRequest(path, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Lua-Client': 'claude-plugin/1.17.1',
'X-Lua-Client': 'claude-plugin/1.17.2',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
Expand Down
6 changes: 3 additions & 3 deletions plugins/lua-agent-builder/mcp/lua-platform/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as tools from './tools/index.mjs';
const TOOL_REGISTRY = Object.values(tools);

const server = new Server(
{ name: 'lua-platform', version: '1.17.1' },
{ name: 'lua-platform', version: '1.17.2' },
{ capabilities: { tools: {} } }
);

Expand Down Expand Up @@ -46,7 +46,7 @@ process.on('uncaughtException', (err) => {
kind: 'uncaughtException',
message: err?.message,
stack: err?.stack,
plugin_version: '1.17.1',
plugin_version: '1.17.2',
lua_cli_version: process.env.LUA_CLI_VERSION ?? null,
platform: process.platform,
ts: new Date().toISOString(),
Expand All @@ -59,7 +59,7 @@ process.on('unhandledRejection', (reason) => {
kind: 'unhandledRejection',
reason: String(reason),
stack: /** @type {any} */ (reason)?.stack,
plugin_version: '1.17.1',
plugin_version: '1.17.2',
lua_cli_version: process.env.LUA_CLI_VERSION ?? null,
platform: process.platform,
ts: new Date().toISOString(),
Expand Down
2 changes: 1 addition & 1 deletion plugins/lua-agent-builder/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-code-lua-plugin",
"version": "1.17.1",
"version": "1.17.2",
"description": "Plugin assets for the lua-agent-builder Claude Code plugin",
"private": true,
"type": "module",
Expand Down
118 changes: 114 additions & 4 deletions plugins/lua-agent-builder/test/hooks/post-deploy-smoke.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { describe, test, expect } from '@jest/globals';
import { decide } from '../../hooks/post-deploy-smoke.mjs';
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { decide, deployProjectDir } from '../../hooks/post-deploy-smoke.mjs';
import { luaWorkingDirs } from '../../lib/tokenizer.mjs';

// The smoke check runs in the agent project the deploy ran in (a folder with lua.skill.yaml): every test below starts
// in one, unless it says otherwise.
let root;
let project;
let startDir;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'smoke-'));
project = join(root, 'agents', 'shop');
mkdirSync(join(project, 'src'), { recursive: true });
writeFileSync(join(project, 'lua.skill.yaml'), 'agent:\n agentId: a1\n');
startDir = process.cwd();
process.chdir(project);
});
afterAll(() => {
process.chdir(startDir);
rmSync(root, { recursive: true, force: true });
});

function fakeSpawn(scripted) {
let i = 0;
Expand Down Expand Up @@ -50,7 +72,7 @@ describe('post-deploy-smoke decide()', () => {
{ exitCode: 1, stdout: '', stderr: 'connection refused', timedOut: false },
]);
const result = await decide({ tool_input: { command } }, { spawnLuaFn });
expect(result?.warn).toContain('agent did not respond');
expect(result?.warn).toContain('the ping to the agent failed (exit=1): connection refused');
expect(spawnLuaFn.calls).toHaveLength(1);
});

Expand Down Expand Up @@ -93,7 +115,7 @@ describe('post-deploy-smoke decide()', () => {
{ tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua deploy skill' } },
{ spawnLuaFn }
);
expect(result?.warn).toContain('agent did not respond');
expect(result?.warn).toContain('the ping to the agent failed');
expect(result?.warn).toContain('exit=1');
});

Expand Down Expand Up @@ -316,4 +338,92 @@ describe('post-deploy-smoke decide()', () => {
expect(result?.warn).toContain('1 error log entry');
expect(spawnLuaFn.calls).toHaveLength(2);
});

// The deploy ran in an agent folder below the shell's directory (`cd agents/shop && lua version promote 3`). Claude
// Code runs the hook elsewhere, so `lua chat` there exited 2 ("No lua.skill.yaml found") and every promote was
// reported as "agent did not respond" while the agent answered.
test('runs the ping and the log scan in the project the deploy ran in, not the hook process folder', async () => {
const spawnLuaFn = fakeSpawn([
{ exitCode: 0, stdout: 'pong', stderr: '', timedOut: false },
{ exitCode: 0, stdout: logsResponse([]), stderr: '', timedOut: false },
]);
const result = await decide(
{ cwd: root, tool_input: { command: 'cd agents/shop && LUA_DEPLOY_CONFIRMED=1 lua version promote 3' } },
{ spawnLuaFn }
);
expect(result).toBeNull();
expect(spawnLuaFn.calls.map(([, opts]) => opts.cwd)).toEqual([project, project]);
});

test('a command run from a subfolder of the project finds it above', async () => {
const spawnLuaFn = fakeSpawn([
{ exitCode: 0, stdout: 'pong', stderr: '', timedOut: false },
{ exitCode: 0, stdout: logsResponse([]), stderr: '', timedOut: false },
]);
await decide({ cwd: join(project, 'src'), tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua deploy all' } }, { spawnLuaFn });
expect(spawnLuaFn.calls[0][1].cwd).toBe(project);
});

test('no project there or above: says the check was skipped and pings nothing', async () => {
const spawnLuaFn = fakeSpawn([]);
const result = await decide(
{ cwd: root, tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua version promote 3' } },
{ spawnLuaFn }
);
expect(result?.warn).toContain('skipped: could not tell which agent project');
expect(result?.warn).not.toContain('did not');
expect(spawnLuaFn.calls).toHaveLength(0);
});

test("a failed ping names the CLI's own reason", async () => {
const spawnLuaFn = fakeSpawn([
{
exitCode: 2,
stdout: '',
stderr: '\n✖ usage: No lua.skill.yaml found — run this command from a Lua project directory.\n',
timedOut: false,
},
]);
const result = await decide({ tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua version promote 3' } }, { spawnLuaFn });
expect(result?.warn).toContain('(exit=2): ✖ usage: No lua.skill.yaml found');
});

test('a ping that times out says the agent did not answer', async () => {
const spawnLuaFn = fakeSpawn([{ exitCode: null, stdout: '', stderr: '', timedOut: true }]);
const result = await decide({ tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua version promote 3' } }, { spawnLuaFn });
expect(result?.warn).toContain('agent did not answer within 20 s');
});

test('a failed ping with no output still says it failed, with its exit code', async () => {
const spawnLuaFn = fakeSpawn([{ exitCode: 3, stdout: '', stderr: '\n \n', timedOut: false }]);
const result = await decide({ tool_input: { command: 'LUA_DEPLOY_CONFIRMED=1 lua version promote 3' } }, { spawnLuaFn });
expect(result?.warn).toContain('the ping to the agent failed (exit=3). Check production logs.');
});

test('deployProjectDir: `cd`, `cd ~` and `cd ~/…` go through the home folder', () => {
expect(deployProjectDir('cd ~/agents/shop && lua deploy all', '/', root)).toBe(project);
expect(deployProjectDir('cd && lua deploy all', '/', project)).toBe(project);
expect(deployProjectDir('cd ~ && lua deploy all', '/', project)).toBe(project);
});

test('deployProjectDir: a computed or `cd -` target is unknown, never guessed', () => {
expect(deployProjectDir('cd "$AGENT" && lua deploy all', root)).toBeNull();
expect(deployProjectDir('cd - && lua deploy all', root)).toBeNull();
expect(deployProjectDir('cd agents/shop && lua deploy all', root)).toBe(project);
});
});

describe('luaWorkingDirs', () => {
test.each([
['lua deploy all', []],
['cd a && lua deploy all', ['a']],
['cd a && cd b; lua version promote 3', ['a', 'b']],
["cd 'my agent' && lua deploy all", ['my agent']],
['cd a && lua deploy all && cd b', ['a']],
['cd && lua deploy all', ['~']],
['cd $X && lua deploy all', null],
['cd a b && lua deploy all', null],
])('%s → %j', (command, dirs) => {
expect(luaWorkingDirs(command)).toEqual(dirs);
});
});
Loading