return error envelopes from failed claude commands (T-391)

Sixteen claude.* handlers reported ok with an `error` field buried in
the payload — `clide claude.agent.set-permission-mode bogus` exited 0,
so scripts could not detect failure, drifting from the D-6 exit-code
contract every other subsystem honors. Missing/invalid args are now
userError, missing sessions notFound, a missing orchestrator
toolError, and a failed task reassign no longer reports ok:false as a
success. No UI consumer read the old payloads. Table-driven test
walks every failure path asserting non-zero codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:28:50 +02:00
co-authored by Claude Fable 5
parent 638869621e
commit 928dede847
5 changed files with 92 additions and 17 deletions
+28 -17
View File
@@ -21,6 +21,13 @@ import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
/// CLI exit), never `ok` with an `error` field a script can't detect.
IpcResponse _userErr(String msg, {String? hint}) =>
IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint));
IpcResponse _notFound(String msg) => IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg));
class ClaudeExtension extends ClideExtension {
@override
String get id => 'builtin.claude';
@@ -95,7 +102,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: show an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
_orchestrator?.show(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
},
@@ -106,7 +113,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: hide an agent session pane',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
_orchestrator?.hide(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
},
@@ -117,7 +124,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: close (kill) an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
await _orchestrator?.close(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
},
@@ -128,7 +135,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: mute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
_orchestrator?.mute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
},
@@ -139,7 +146,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: unmute broker delivery to an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
_orchestrator?.unmute(id);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
},
@@ -151,9 +158,9 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: inject a text turn into an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
final text = args.skip(1).join(' ');
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
if (text.isEmpty) return _userErr('missing message text');
_orchestrator?.injectMessage(id, text);
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
},
@@ -169,12 +176,12 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: set permission mode for an agent session',
run: (args) async {
final id = args.firstOrNull;
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
if (id == null) return _userErr('missing session id');
final mode = args.length >= 2 ? args[1] : null;
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
if (!valid.contains(mode)) {
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
}
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
@@ -188,7 +195,7 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: Cycle permission mode',
run: (_) async {
final managed = _orchestrator?.byId('primary');
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
if (managed == null) return _notFound('no primary session');
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
managed.session.setPermissionMode(next);
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
@@ -200,11 +207,12 @@ class ClaudeExtension extends ClideExtension {
command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent',
run: (args) async {
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
final taskId = args[0];
final toId = args[1];
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
},
),
// T-180: full team chat pane opened as a workspace tab.
@@ -241,7 +249,7 @@ class ClaudeExtension extends ClideExtension {
command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user',
run: (args) async {
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
if (args.isEmpty) return _userErr('usage: [@name] <text>');
final raw = args.join(' ');
String? recipient;
String body = raw;
@@ -269,15 +277,18 @@ class ClaudeExtension extends ClideExtension {
run: (args) async {
final sourceId = args.firstOrNull;
if (sourceId == null) {
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
}
final orch = _orchestrator;
if (orch == null) {
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
);
}
final source = orch.byId(sourceId);
if (source == null) {
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
return _notFound('unknown session "$sourceId"');
}
final cwd = args.length >= 2 ? args[1] : source.cwd;
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';