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
9 changes: 4 additions & 5 deletions packages/dmoss-agent/src/cli/approval-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,10 @@ function editFileDetail(input: Record<string, unknown>): string[] | null {
const oldString = typeof input.old_string === 'string' ? input.old_string : undefined;
const newString = typeof input.new_string === 'string' ? input.new_string : undefined;
if (oldString === undefined || newString === undefined) return null;
const lines = [
...oldString.split('\n').map((line) => `- ${line}`),
...newString.split('\n').map((line) => `+ ${line}`),
];
return lines.length ? lines : null;
const diff = diffLinesForApproval(oldString, newString);
if (!diff) return null;
if (diff.every((line) => line.startsWith(' …'))) return null;
return diff;
}

function writeFileDetail(input: Record<string, unknown>, ctx: ApprovalDetailContext): string[] | null {
Expand Down
5 changes: 4 additions & 1 deletion packages/dmoss-agent/src/cli/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ function isReadonlyTail(tokens: readonly string[]): boolean {

function isReadonlySed(tokens: readonly string[]): boolean {
if (tokens.some((token) => token === '-i' || token.startsWith('-i') || token === '--in-place' || token.startsWith('--in-place='))) return false;
return tokens.some((token) => token === '-n' || token === '--quiet' || token === '--silent' || (/^-[A-Za-z]*n[A-Za-z]*$/.test(token)));
// Default sed behavior (no -i flag) is readonly: it prints to stdout without
// modifying the file. The -n flag is just a convenience for quiet mode, but
// sed without -n is still readonly.
return true;
}

function isReadonlyFind(tokens: readonly string[]): boolean {
Expand Down
17 changes: 16 additions & 1 deletion packages/dmoss-agent/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,16 @@ function applyConfigOverride(target: CliConfigOverrides, pair: string): void {
return;
}
if (key === 'trustedTools') {
if (value.trim() === '') {
throw new Error(`Unsupported --config key "trustedTools"; empty value not allowed (omit to use defaults)`);
}
target.trustedTools = parseTrustedTools(value) ?? [];
return;
}
if (key === 'deniedTools') {
if (value.trim() === '') {
throw new Error(`Unsupported --config key "deniedTools"; empty value not allowed (omit to use defaults)`);
}
target.deniedTools = parseTrustedTools(value) ?? [];
return;
}
Expand All @@ -124,7 +130,16 @@ function applyConfigOverride(target: CliConfigOverrides, pair: string): void {
target[key] = parsed;
return;
}
if (key === 'model' || key === 'provider' || key === 'baseUrl' || key === 'workspace') {
if (key === 'model' || key === 'provider' || key === 'baseUrl') {
if (!value.trim()) {
throw new Error(`Unsupported --config key "${key}"; empty value not allowed`);
}
target[key] = value;
}
if (key === 'workspace') {
if (!value.trim()) {
throw new Error(`Unsupported --config key "workspace"; empty value not allowed (use -C with a path)`);
}
target[key] = value;
}
}
Expand Down
20 changes: 15 additions & 5 deletions packages/dmoss-agent/src/core/goal/task-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,8 @@ export function detectContinuationIntent(userMessage: string): ContinuationInten
compact,
);
const englishPhrase =
/^(continue|resume|go on|carry on|next step|keep going|please continue|pls continue)$/iu.test(
raw,
/^(continue|resume|goon|carryon|nextstep|keepgoing|pleasecontinue|plscontinue)$/iu.test(
compact,
);
const politeContinue =
/^(请|麻烦|帮我|劳烦|辛苦)(你|您)?(请)?(继续|接着|往下|执行|处理|生成)/iu.test(compact) ||
Expand All @@ -240,7 +240,7 @@ export function createOrUpdateTaskFrame(params: {
return {
...params.previous,
runId: params.runId,
status: params.previous.status === 'completed' ? 'active' : params.previous.status,
status: params.previous.status === 'completed' || params.previous.status === 'aborted' ? 'active' : params.previous.status,
source: 'user',
updatedAt: now,
nextAction: params.previous.nextAction || 'Continue from the latest saved task state.',
Expand Down Expand Up @@ -393,7 +393,12 @@ export function recordTaskFrameCompaction(
frame: TaskFrame,
params: { summaryChars: number; droppedMessages: number; now?: number },
): TaskFrame {
const next = { ...frame, source: 'compaction' as const, updatedAt: params.now ?? Date.now() };
const next = {
...frame,
completedSteps: [...frame.completedSteps],
source: 'compaction' as const,
updatedAt: params.now ?? Date.now(),
};
uniquePush(
next.completedSteps,
`Saved context checkpoint (${params.summaryChars} chars, ${params.droppedMessages} messages folded)`,
Expand All @@ -417,7 +422,12 @@ export function recordTaskFrameAssistant(
stopReason: string,
now = Date.now(),
): TaskFrame {
const next = { ...frame, source: 'assistant' as const, updatedAt: now };
const next = {
...frame,
completedSteps: [...frame.completedSteps],
source: 'assistant' as const,
updatedAt: now,
};
const visible = cleanText(text, MAX_SHORT_TEXT);
if (visible) uniquePush(next.completedSteps, `Assistant response: ${visible}`);
if (stopReason === 'end_turn' || stopReason === 'stop_sequence') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function mapUsage(usage: LLMResponse['usage'] | undefined): Usage {
output: usage.outputTokens,
cacheRead,
cacheWrite,
totalTokens: usage.inputTokens + usage.outputTokens,
totalTokens: usage.inputTokens + usage.outputTokens + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function planContextBudgetActions(
if (input.estimatedPromptTokens >= warningThreshold) {
actions.push({
kind: 'snip_tail_tool_results',
reason: 'warning_threshold',
reason: pressureReason === 'proactive_threshold' ? 'proactive_threshold' : 'warning_threshold',
});
}

Expand Down
9 changes: 6 additions & 3 deletions packages/dmoss-agent/src/core/loop/follow-up-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function lastMessageNeedsToolFollowUp(messages: readonly MessageLike[]):
const last = messages[messages.length - 1];
if (last.role !== 'user') return false;
if (typeof last.content === 'string') return false;
return (last.content as LLMContentBlock[]).some((b) => b.type === 'tool_result');
return (last.content as LLMContentBlock[]).some((b) => b && b.type === 'tool_result');
}

/**
Expand Down Expand Up @@ -278,12 +278,15 @@ export interface FollowUpGuardConfig {
extraPatterns?: FollowUpPattern[];
/** Max follow-up nudges per turn (default 1) */
maxFollowUps?: number;
/** Max consecutive follow-up turns before giving up (prevents infinite nudge loops) */
/**
* @deprecated unused — declared but never read. Hardcoded limit is 1.
* Max consecutive follow-up turns before giving up (prevents infinite nudge loops)
*/
maxConsecutiveFollowUps?: number;
}

export const DEFAULT_FOLLOW_UP_GUARD_CONFIG: FollowUpGuardConfig = {
enabled: true,
maxFollowUps: 1,
maxConsecutiveFollowUps: 1,
// maxConsecutiveFollowUps is unused — see interface deprecation note
};
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,5 @@ export function runPerTurnContextManagement(
});
}

return { savedChars };
return { savedChars, savedTokens };
}
8 changes: 8 additions & 0 deletions packages/dmoss-agent/src/core/session/session-jsonl-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,27 @@ export async function loadSessionFile(
};
const entries: SessionEntry[] = [];

const entryIds = new Set<string>();
for (const entry of rest) {
if (!entry || typeof entry !== "object") continue;
const typed = entry as SessionEntry;
if (!typed.type || typeof typed.id !== "string") continue;
if (typed.type === "message" && (typed as MessageEntry).message) {
entries.push(typed);
entryIds.add(typed.id);
continue;
}
if (
typed.type === "compaction" &&
typeof (typed as CompactionEntry).summary === "string" &&
typeof (typed as CompactionEntry).firstKeptEntryId === "string"
) {
const comp = typed as CompactionEntry;
if (!entryIds.has(comp.firstKeptEntryId)) {
console.warn(
`compaction entry has nonexistent firstKeptEntryId: ${comp.firstKeptEntryId}`
);
}
entries.push(typed);
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/dmoss-agent/src/core/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,13 @@ async function rewriteSessionFile(
lines = [headerLine, ...kept];
content = `${lines.join("\n")}\n`;

// Final validation: ensure rotated content fits within the size cap
while (Buffer.byteLength(content, "utf-8") > MAX_SESSION_FILE_BYTES && kept.length > 0) {
kept.shift();
lines = [headerLine, ...kept];
content = `${lines.join("\n")}\n`;
}

// Archive the oversized file (best-effort; ignore if it doesn't exist yet)
try {
await fs.rename(state.filePath, `${state.filePath}.1`);
Expand Down
19 changes: 15 additions & 4 deletions packages/dmoss-agent/src/mcp/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class McpServerConnection {
private process: ChildProcess;
private nextId = 1;
private pending = new Map<number, PendingRequest>();
private MAX_REQUEST_ID = 2147483647; // ~2.1B, safe integer well below MAX_SAFE_INTEGER
private buffer = '';
private closed = false;
private requestTimeoutMs: number;
Expand Down Expand Up @@ -256,7 +257,7 @@ class McpServerConnection {
this.process.on('error', (err) => {
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(err);
pending.reject(new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP server ${serverName} process error: ${err instanceof Error ? err.message : String(err)}` }));
}
this.pending.clear();
});
Expand Down Expand Up @@ -305,6 +306,7 @@ class McpServerConnection {
if (this.closed) throw new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP server ${this.serverName} is closed` });
if (signal?.aborted) throw new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP request aborted: ${signal.reason ?? 'aborted'}` });
const id = this.nextId++;
if (this.nextId > this.MAX_REQUEST_ID) this.nextId = 1;
const msg: JsonRpcRequest = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
const sendCancellation = (reason: string) => {
Expand Down Expand Up @@ -339,7 +341,14 @@ class McpServerConnection {
reject: (e) => { cleanup(); reject(e); },
timer,
});
this.process.stdin!.write(JSON.stringify(msg) + '\n');
try {
this.process.stdin!.write(JSON.stringify(msg) + '\n');
} catch (err) {
this.pending.delete(id);
clearTimeout(timer);
cleanup();
reject(new DmossError({ code: ErrorCode.MCP_CONNECTION_FAILED, message: `MCP server ${this.serverName} stdin write failed: ${err instanceof Error ? err.message : String(err)}` }));
}
});
}

Expand Down Expand Up @@ -377,10 +386,12 @@ class McpServerConnection {
this.process.kill('SIGKILL');
resolve();
}, 3000);
this.process.on('exit', () => {
const onExit = () => {
this.process.off('exit', onExit);
clearTimeout(timeout);
resolve();
});
};
this.process.once('exit', onExit);
});
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/dmoss-agent/src/provider/pi-ai-stream-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function processEvent(
? 'max_tokens'
: 'end_turn';
const msg = event.message;
const evtUsage = event.usage ?? msg?.usage;
const evtUsage = event.usage !== undefined && event.usage !== null ? event.usage : msg?.usage;

if (msg?.content && Array.isArray(msg.content)) {
const hasTextInContent = content.some(
Expand Down Expand Up @@ -325,7 +325,12 @@ export function convertStreamEvent(event: PiAiStreamEvent): LLMStreamEvent | nul
const sr = event.stopReason ?? event.reason;
return {
type: 'message_delta',
stopReason: sr === 'toolCall' || sr === 'toolUse' ? 'tool_use' : 'end_turn',
stopReason:
sr === 'toolCall' || sr === 'toolUse'
? 'tool_use'
: sr === 'length'
? 'max_tokens'
: 'end_turn',
};
}
return null;
Expand Down
9 changes: 8 additions & 1 deletion packages/dmoss-agent/src/tools/device-ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,18 @@ export function getDeviceConfigFromEnv(): DeviceSshConfig | null {

const rawDomain = process.env.DMOSS_ROS_DOMAIN_ID;
const parsedDomain = rawDomain !== undefined ? Number.parseInt(rawDomain, 10) : NaN;

const portStr = process.env.DMOSS_DEVICE_PORT || '22';
const port = Number.parseInt(portStr, 10);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid DMOSS_DEVICE_PORT: "${portStr}" (must be 1-65535)`);
}

return {
host,
user: process.env.DMOSS_DEVICE_USER || 'root',
password: process.env.DMOSS_DEVICE_PASSWORD,
port: parseInt(process.env.DMOSS_DEVICE_PORT || '22', 10),
port,
keyPath: process.env.DMOSS_DEVICE_KEY,
...(Number.isInteger(parsedDomain) && parsedDomain >= 0 ? { rosDomainId: parsedDomain } : {}),
};
Expand Down
8 changes: 6 additions & 2 deletions packages/dmoss-agent/src/utils/run-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ export function runProcess(cmd: string, opts: RunProcessOptions): Promise<RunPro
}

const onAbort = () => kill();
opts.signal?.addEventListener('abort', onAbort, { once: true });
if (opts.signal) {
opts.signal.addEventListener('abort', onAbort, { once: true });
}

const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
opts.signal?.removeEventListener('abort', onAbort);
if (opts.signal) {
opts.signal.removeEventListener('abort', onAbort);
}
};

child.stdout?.on('data', (chunk: Buffer) => {
Expand Down
35 changes: 35 additions & 0 deletions packages/dmoss-agent/test/cli-approval-edit-detail.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { buildApprovalDetailLines } from '../dist/cli/approval-detail.js';

// Test that editFileDetail shows a minimal diff, not all old/new lines
{
const oldContent = `line 1
line 2
line 3
line 4
line 5`;
const newContent = `line 1
line 2 CHANGED
line 3
line 4
line 5`;

const lines = buildApprovalDetailLines('edit_file', 'local_write', {
old_string: oldContent,
new_string: newContent,
});

// Should show a minimal diff with context ellipsis, not all 5 lines removed + 5 lines added
assert.ok(lines.some((l) => l.startsWith(' …')), 'should show context ellipsis for unchanged lines');
assert.ok(lines.some((l) => l.startsWith('- line 2')), 'should show the removed line');
assert.ok(lines.some((l) => l.startsWith('+ line 2 CHANGED')), 'should show the added line');

// The key assertion: we should NOT have all 5 old lines and all 5 new lines
const minusLines = lines.filter((l) => l.startsWith('- ') && !l.startsWith(' …'));
const plusLines = lines.filter((l) => l.startsWith('+ ') && !l.startsWith(' …'));
assert.equal(minusLines.length, 1, 'should only show the 1 changed old line, not all 5');
assert.equal(plusLines.length, 1, 'should only show the 1 changed new line, not all 5');
}

console.log('[PASS] editFileDetail uses minimal diff');
Loading
Loading