feat: codex app-server integration, token sync fix, typing fix

- Rewrite codex-runner to use `codex app-server` JSON-RPC instead of
  `codex exec`. Supports streaming (item/agentMessage/delta), session
  persistence (thread/start, thread/resume), and mid-turn message
  injection (turn/steer with IPC polling during execution).
- Fix token refresh not syncing to per-group session directories,
  causing 401 errors on running agents.
- Fix typing indicator staying on when codex returns null result
  by always clearing typing on any streaming callback.
- Update README and CLAUDE.md to reflect dual-service architecture,
  host process mode, and codex app-server integration.
This commit is contained in:
Eyejoker
2026-03-13 16:03:08 +09:00
parent 0515edc93d
commit 35277cea0a
6 changed files with 584 additions and 290 deletions

View File

@@ -275,10 +275,7 @@ function prepareGroupEnvironment(
? fs.readFileSync(configTomlPath, 'utf-8')
: '';
// Remove existing nanoclaw MCP section if present (to refresh env vars)
toml = toml.replace(
/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/,
'',
);
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
const mcpSection = `
[mcp_servers.nanoclaw]
command = "node"

View File

@@ -52,7 +52,10 @@ import {
isSessionCommandAllowed,
} from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { startTokenRefreshLoop, stopTokenRefreshLoop } from './token-refresh.js';
import {
startTokenRefreshLoop,
stopTokenRefreshLoop,
} from './token-refresh.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -257,10 +260,12 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
await channel.sendMessage(chatJid, text);
outputSentToUser = true;
}
await channel.setTyping?.(chatJid, false);
resetIdleTimer();
}
// Always clear typing and reset idle timer on any output (including null results)
await channel.setTyping?.(chatJid, false);
resetIdleTimer();
if (result.status === 'success') {
queue.notifyIdle(chatJid);
}

View File

@@ -12,6 +12,7 @@ import os from 'os';
import path from 'path';
import { logger } from './logger.js';
import { DATA_DIR } from './config.js';
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
const FALLBACK_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
@@ -68,6 +69,30 @@ function writeCredentials(creds: CredentialsFile): void {
const tempPath = `${credsPath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, credsPath);
// Sync to all per-group session directories so running agents pick up the new token
syncToSessionDirs(credsPath);
}
function syncToSessionDirs(credsPath: string): void {
const sessionsDir = path.join(DATA_DIR, 'sessions');
try {
if (!fs.existsSync(sessionsDir)) return;
const groups = fs.readdirSync(sessionsDir);
let synced = 0;
for (const group of groups) {
const dest = path.join(sessionsDir, group, '.claude', '.credentials.json');
if (fs.existsSync(path.dirname(dest))) {
fs.copyFileSync(credsPath, dest);
synced++;
}
}
if (synced > 0) {
logger.info({ count: synced }, 'Synced credentials to session directories');
}
} catch (err) {
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Failed to sync credentials to sessions');
}
}
async function refreshToken(
@@ -87,10 +112,7 @@ async function refreshToken(
for (const url of [TOKEN_URL, FALLBACK_TOKEN_URL]) {
try {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const res = await fetch(url, {
method: 'POST',