Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ async function checkAndRestoreSyncData(
userId: string,
config: LoadedConfig
): Promise<void> {
if (config.sync?.enabled !== true) return;
try {
// Create sync service to check for cloud data
const syncService = createSyncService({
Expand Down
4 changes: 3 additions & 1 deletion src/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export async function sessions(ctx: {
: undefined;

try {
const allSessions = await ctx.sessionManager.listSessions(
const storedSessions = await ctx.sessionManager.listSessions(
projectFilter ? { project: projectFilter } : undefined
);

const allSessions = storedSessions.filter(session => session.messageCount > 0);

if (allSessions.length === 0) {
console.log(chalk.gray(t('commands.sessions.noSessions')));
return null;
Expand Down
7 changes: 7 additions & 0 deletions src/commands/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,13 @@ export function setConfigSetting(config: LoadedConfig, keyInput: string, rawValu
return { key: 'provider', value: provider };
}

if (key === 'tools.maxCommandOutputChars') {
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < 1024) throw new Error('tools.maxCommandOutputChars must be an integer of at least 1024.');
config.tools = { ...config.tools, maxCommandOutputChars: value };
return { key, value };
}

const providerConfigKey = normalizeProviderConfigKey(key);
if (providerConfigKey) {
const current = config[providerConfigKey.provider];
Expand Down
30 changes: 25 additions & 5 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import readline from 'node:readline';
import { StdinBuffer } from '../ui/StdinBuffer.js';
import type { SlashCommandContext } from '../core/slashCommandTypes.js';
import { loadConfig, saveConfig } from '../config.js';
import type { AutohandConfig } from '../types.js';
import type { AutohandConfig, LoadedConfig } from '../types.js';
import { formatSyncSkippedFiles } from '../sync/skipReport.js';
import type { SyncSkippedFiles } from '../sync/rootBudgets.js';
import type { SyncService } from '../sync/SyncService.js';
import {
getSyncService as getRuntimeSyncService,
setBackgroundSyncEnabled,
setSyncService as setRuntimeSyncService,
} from '../sync/runtimeSyncService.js';
import { createCommandTheme } from './commandTheme.js';
Expand Down Expand Up @@ -56,6 +57,11 @@ export function getSyncService(): SyncService | null {
export async function sync(ctx: SlashCommandContext, args: string[] = []): Promise<string | null> {
const config = await loadConfig();
const isLoggedIn = Boolean(config.auth?.token && config.auth?.user);
if (args[0] === 'off') {
await updateSyncEnabled(config, false);
console.log(createCommandTheme().accent(t('commands.sync.disabled')));
return null;
}

if (!isLoggedIn) {
const theme = createCommandTheme();
Expand All @@ -64,6 +70,12 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi
return null;
}

if (args[0] === 'on') {
await updateSyncEnabled(config, true);
console.log(createCommandTheme().accent(t('commands.sync.enabled')));
return null;
}

// Gather sync data
const data = await gatherSyncData(ctx, config);

Expand All @@ -78,6 +90,14 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi
return null;
}

async function updateSyncEnabled(config: LoadedConfig, enabled: boolean): Promise<void> {
// Stop the timer and in-flight work before the asynchronous config write.
if (!enabled) setBackgroundSyncEnabled(false);
config.sync = { ...config.sync, enabled };
await saveConfig(config);
if (enabled) setBackgroundSyncEnabled(true);
}

async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig): Promise<SyncData> {
const syncService = getRuntimeSyncService();
let status: Awaited<ReturnType<SyncService['getStatus']>> = {
Expand All @@ -97,7 +117,7 @@ async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig):
}

return {
enabled: config.sync?.enabled !== false,
enabled: syncService ? status.enabled : config.sync?.enabled === true,
isRunning: syncService?.isRunning ?? false,
lastSync: status.lastSync,
lastError: status.lastError,
Expand Down Expand Up @@ -198,11 +218,11 @@ function renderSyncUI(data: SyncData, ctx: SlashCommandContext): Promise<void> {
// Toggle sync enabled
try {
const config = await loadConfig();
const newEnabled = config.sync?.enabled === false;
config.sync = { ...config.sync, enabled: newEnabled };
await saveConfig(config);
const newEnabled = config.sync?.enabled !== true;
await updateSyncEnabled(config, newEnabled);
if (closed) return;
data.enabled = newEnabled;
data.isRunning = data.syncService?.isRunning ?? false;
console.log(createCommandTheme().accent(`\n${newEnabled ? t('commands.sync.enabled') : t('commands.sync.disabled')}`));
await sleep(1000);
render();
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,13 @@ function validateShellSettings(config: AutohandConfig, configPath: string): void

function validateConfig(config: AutohandConfig, configPath: string): void {
validateShellSettings(config, configPath);
if (config.tools !== undefined) {
if (!isPlainObject(config.tools)) throw new Error(`tools must be an object in ${configPath}`);
const limit = config.tools.maxCommandOutputChars;
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1024)) {
throw new Error(`tools.maxCommandOutputChars must be an integer of at least 1024 in ${configPath}`);
}
}
if (config.traces !== undefined) {
if (!isPlainObject(config.traces)) {
throw new Error(`traces must be an object in ${configPath}`);
Expand Down
29 changes: 24 additions & 5 deletions src/core/actionExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import chalk from 'chalk';
import { CommandModelOutput, resolveMaxCommandOutputChars } from '../utils/commandModelOutput.js';
import { formatToolObservation } from './agent/AgentToolOutputRuntime.js';
import { setBounded } from '../utils/bounded.js';
import path from 'node:path';
import fse from 'fs-extra';
Expand Down Expand Up @@ -176,6 +178,7 @@ export interface ActionExecutorOptions {
projectManager?: ProjectManager;
sessionId?: string;
getCurrentSessionId?: () => string | undefined;
getCommandOutputDirectory?: () => string | undefined;
onExploration?: (entry: ExplorationEvent) => void;
toolsRegistry?: ToolsRegistry;
metaToolService?: MetaToolService;
Expand Down Expand Up @@ -260,6 +263,7 @@ const READ_FILE_MAX_BYTES = 128 * 1024;
const READ_FILE_MAX_LINE_CHARACTERS = 2_000;

interface ToolOutcomeCapture {
commandOutput?: CommandModelOutput;
failure?: ToolFailureOutcome;
imagePaths?: string[];
}
Expand Down Expand Up @@ -922,9 +926,20 @@ export class ActionExecutor {
}

const capture: ToolOutcomeCapture = {};
const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => capture.imagePaths?.length
? { ...outcome, imagePaths: capture.imagePaths }
: outcome;
if ((action.type === 'run_command' || action.type === 'shell') && !action.background && (action.type !== 'run_command' || !action.interactive)) {
capture.commandOutput = new CommandModelOutput(
resolveMaxCommandOutputChars(this.runtime.config.tools?.maxCommandOutputChars),
this.deps.getCommandOutputDirectory?.(),
);
}
const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => {
if (capture.commandOutput) {
const observation = formatToolObservation({ tool: action.type, ...outcome });
const modelOutput = capture.commandOutput.finish(observation);
if (modelOutput !== observation) outcome = { ...outcome, modelOutput };
}
return capture.imagePaths?.length ? { ...outcome, imagePaths: capture.imagePaths } : outcome;
};
try {
const output = await this.withToolActivity(
action,
Expand Down Expand Up @@ -2016,10 +2031,12 @@ export class ActionExecutor {
shell: true,
signal: context?.signal,
onStdout: (chunk) => {
capture?.commandOutput?.append(chunk);
emitOutput('stdout', chunk);
emitLiveOutput('stdout', chunk);
},
onStderr: (chunk) => {
capture?.commandOutput?.append(chunk);
emitOutput('stderr', chunk);
emitLiveOutput('stderr', chunk);
},
Expand Down Expand Up @@ -2123,8 +2140,8 @@ export class ActionExecutor {
cmdStr,
this.runtime.workspaceRoot,
{
onStdout: (chunk) => this.onLiveCommandOutput!(liveId, 'stdout', chunk),
onStderr: (chunk) => this.onLiveCommandOutput!(liveId, 'stderr', chunk),
onStdout: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stdout', chunk); },
onStderr: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stderr', chunk); },
preferPty: process.stdin.isTTY && process.stdout.isTTY,
columns: process.stdout.columns,
rows: process.stdout.rows,
Expand Down Expand Up @@ -2195,6 +2212,8 @@ export class ActionExecutor {
shell: true,
background: action.background,
signal: context?.signal,
onStdout: chunk => capture?.commandOutput?.append(chunk),
onStderr: chunk => capture?.commandOutput?.append(chunk),
...(action.background ? {
onBackgroundExit: () => {
if (fallbackBackgroundRegistryId !== undefined) {
Expand Down
1 change: 1 addition & 0 deletions src/core/agent/AgentDependencyComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ export function initializeAgentDependencies(
toolsRegistry: host.toolsRegistry,
getRegisteredTools: () => host.toolManager?.listDefinitions() ?? [],
getCurrentSessionId: () => host.sessionManager?.getCurrentSession?.()?.metadata?.sessionId,
getCommandOutputDirectory: () => host.sessionManager?.getCurrentSession?.()?.getArtifactDirectory?.(),
memoryManager: host.memoryManager,
permissionManager: host.permissionManager,
onFileModified: (filePath, changeType, toolCallId) => {
Expand Down
4 changes: 4 additions & 0 deletions src/core/agent/AgentToolOutputRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export function handleAgentToolOutput(
if (process.env.AUTOHAND_STREAM_TOOL_OUTPUT !== '1') {
return;
}
// Commands supply one bounded final observation; live chunks remain terminal-only.
if (chunk.tool === 'run_command' || chunk.tool === 'shell') return;
if (!chunk.toolCallId || !chunk.data) {
return;
}
Expand All @@ -37,6 +39,7 @@ export function queueAgentToolMessageChunk(
toolCallId: string,
stream?: 'stdout' | 'stderr'
): void {
if (name === 'run_command' || name === 'shell') return;
const session = host.sessionManager.getCurrentSession();
if (!session) return;

Expand Down Expand Up @@ -76,6 +79,7 @@ export async function saveAgentToolMessage(
}

export function formatToolObservation(result: ToolExecutionResult): string {
if (result.modelOutput !== undefined) return result.modelOutput;
if (result.success) return result.output ?? '(no output)';
if (!result.output) return result.error ?? 'Tool failed without error message';
if (!result.error || result.output.includes(result.error)) return result.output;
Expand Down
7 changes: 7 additions & 0 deletions src/core/agent/CodeModeRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ function isArgumentObject(value: unknown): value is NonNullable<ToolCallRequest[
}

function toOutcome(result: ToolExecutionResult): SandboxToolOutcome {
if (result.modelOutput !== undefined) {
return result.success
? { ok: true, output: result.modelOutput,
...(result.imagePaths?.length || result.imageRefs?.length ? { images: true } : {}) }
: { ok: false, kind: result.kind, error: 'Command failed.', output: result.modelOutput };
}

if (result.success) {
return {
ok: true, output: result.output ?? '',
Expand Down
4 changes: 4 additions & 0 deletions src/core/toolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3600,7 +3600,11 @@ export class ToolManager {
|| outcome.imageRefs.some(value => typeof value !== 'string' || !TOOL_IMAGE_REF.test(value)))) {
throw new Error('Tool executor returned malformed runtime image references.');
}
if (outcome.modelOutput !== undefined && typeof outcome.modelOutput !== 'string') {
throw new Error('Tool executor returned malformed model output.');
}
const images = {
...(outcome.modelOutput === undefined ? {} : { modelOutput: outcome.modelOutput }),
...(outcome.imagePaths === undefined ? {} : { imagePaths: [...outcome.imagePaths] }),
...(outcome.imageRefs === undefined ? {} : { imageRefs: [...outcome.imageRefs] }),
};
Expand Down
11 changes: 4 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ program
.conflicts('tracesOn'))
.option('--login', 'Sign in to your Autohand account', false)
.option('--logout', 'Sign out of your Autohand account', false)
.option('--sync-settings [bool]', 'Enable/disable settings sync (default: true for logged users)')
.option('--sync-settings [bool]', 'Enable/disable settings sync (default: false)')
.option('--patch', 'Generate git patch without applying changes (requires --prompt)', false)
.option('--output <file>', 'Output file for patch (default: stdout, used with --patch)')
.option('--mode <mode>', 'Run mode: interactive (default), rpc, or acp', 'interactive')
Expand Down Expand Up @@ -2053,11 +2053,8 @@ async function runCLI(options: InternalCLIOptions): Promise<void> {
onVersionResult: (versionResult) => {
agentHolder.current?.setVersionCheckResult(versionResult);
},
shouldStartSync: () => Boolean(
config.auth?.token
&& options.syncSettings !== false
&& config.sync?.enabled !== false
),
shouldCreateSync: () => Boolean(config.auth?.token),
shouldStartSync: () => Boolean(config.auth?.token && (options.syncSettings ?? config.sync?.enabled ?? false)),
createSyncService: async (authUser) => {
const { createSyncService, DEFAULT_SYNC_CONFIG, SyncApiClient } = await import('./sync/index.js');
let lastUnavailableCount = 0;
Expand All @@ -2077,7 +2074,7 @@ async function runCLI(options: InternalCLIOptions): Promise<void> {
config: {
...DEFAULT_SYNC_CONFIG,
...config.sync,
enabled: true,
enabled: options.syncSettings ?? config.sync?.enabled ?? false,
},
onSyncFailure: async (reason) => {
const oneLine = reason.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').slice(0, 300).replace(/[.\s]+$/u, '');
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/CliRuntimeResourceOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface CliBackgroundStartup<
versionResult: VersionResult | null;
}>;
onVersionResult(result: VersionResult): void;
/** Prepare a disabled service so the user can opt in during this process. */
shouldCreateSync?(authUser: AuthUser): boolean;
shouldStartSync(authUser: AuthUser): boolean;
createSyncService(authUser: AuthUser): Promise<Service>;
}
Expand Down Expand Up @@ -168,7 +170,9 @@ export class CliRuntimeResourceOwner<
if (versionResult) {
startup.onVersionResult(versionResult);
}
if (!authUser || !startup.shouldStartSync(authUser)) return;
if (!authUser) return;
const shouldStart = startup.shouldStartSync(authUser);
if (!shouldStart && !startup.shouldCreateSync?.(authUser)) return;

const service = await startup.createSyncService(authUser);
if (!this.isGenerationActive(generation)) {
Expand All @@ -178,7 +182,7 @@ export class CliRuntimeResourceOwner<

this.syncService = service;
try {
service.start();
if (shouldStart) service.start();
if (!this.isGenerationActive(generation)) {
this.syncService = null;
await this.stopService(service);
Expand Down
9 changes: 8 additions & 1 deletion src/session/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,9 @@ export class SessionManager {
const activeAt = Date.parse(metadata.lastActiveAt);
return Number.isFinite(activeAt) ? activeAt : Date.parse(metadata.createdAt) || 0;
};
return sessions.reduce<SessionMetadata | null>((latest, session) => {
const conversations = sessions.filter(session => session.messageCount > 0);
const candidates = conversations.length > 0 ? conversations : sessions;
return candidates.reduce<SessionMetadata | null>((latest, session) => {
return !latest || activityTime(session) > activityTime(latest) ? session : latest;
}, null);
}
Expand Down Expand Up @@ -584,6 +586,11 @@ export class Session {
return !this.persist;
}

/** Artifact persistence follows the same boundary as the session transcript. */
getArtifactDirectory(): string | undefined {
return this.persist ? this.sessionDir : undefined;
}

private async ensureSessionDir(): Promise<void> {
await fs.ensureDir(this.sessionDir);
}
Expand Down
23 changes: 16 additions & 7 deletions src/sync/SyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export class SyncService {
constructor(options: SyncServiceOptions) {
this.authToken = options.authToken;
this.userId = options.userId;
this.config = options.config;
this.config = { ...options.config };
this.client = options.apiClient || getSyncApiClient();
this.onEvent = options.onEvent || (() => {});
this.onAuthFailure = options.onAuthFailure;
Expand All @@ -185,7 +185,7 @@ export class SyncService {
* Start the background sync timer
*/
start(): void {
if (this.started || this.stopped) return;
if (this.started || this.stopped || !this.config.enabled) return;
this.started = true;

// Run initial sync with proper error handling
Expand All @@ -210,11 +210,20 @@ export class SyncService {
* Stop the background sync timer
*/
stop(): void {
if (!this.stopped) {
this.stopped = true;
this.generation++;
this.operationController?.abort(new SyncOperationStoppedError());
}
this.stopped = true;
this.pause();
}

/** Pause immediately without making an explicit later opt-in impossible. */
setEnabled(enabled: boolean): void {
this.config.enabled = enabled;
if (enabled) this.start();
else this.pause();
}

private pause(): void {
this.generation++;
this.operationController?.abort(new SyncOperationStoppedError());
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
Expand Down
Loading