Skip to content

Commit 276c000

Browse files
fix: add local embedded TUI mode (#66767) (thanks @fuller-stack-dev)
* feat(tui): add local embedded TUI mode with terminal/chat aliases Adds a gateway-free local TUI path so users can run openclaw in their terminal without needing a running gateway process. - TuiBackend interface abstraction (tui-backend.ts) with EmbeddedTuiBackend implementation that drives the agent loop in-process - openclaw tui --local flag for local embedded mode - openclaw terminal / openclaw chat aliases that imply --local - /auth slash command with codex CLI delegation to avoid prolite plan issue - Default model display fallback on startup - Local-aware status text and log suppression - Concise auth error hints, raw HTML 403 suppression - Onboarding hatch flow launches local TUI (no gateway required) - Commander alias bug fix in run-main.ts (.aliases() check) - All new and updated tests passing (145/145) * TUI: fix alias detection, cross-platform codex lookup, and history byte-budget safeguards * TUI: remove RuntimeEnv type annotation to fix CI oxlint error * TUI: filter gateway-dependent tools and auto-approve plugin hooks in embedded mode * TUI: suppress console noise and add embedded mode system prompt note * TUI: reduce embedded-mode tool filtering from 15 to 7, add local session tools * TUI: fix remaining PR review comments * TUI: address latest review feedback and CI drift * Core: align prompt helper with latest base * Core: match prompt helper formatting with base * Core: restore prompt helper from latest base * fix(tui): preserve local auth fallback in source checkouts * fix(tts): guard telephony provider invocation * fix(tui): support Windows codex auth shim * fix(tui): harden local auth flow * fix: preserve embedded tool-first run events * fix(tui): keep embedded plugin approvals gated * fix(tui): restore embedded attempt import * fix(tui): resolve sessions in embedded stub * fix: add embedded TUI changelog entry (#66767) (thanks @fuller-stack-dev) * fix: pass setup TUI local mode through relaunch (#66767) (thanks @fuller-stack-dev) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent d733d54 commit 276c000

39 files changed

Lines changed: 2250 additions & 203 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010
- CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.
1111
- WhatsApp/groups+direct: forward per-group and per-direct `systemPrompt` config into inbound context `GroupSystemPrompt` so configured per-chat behavioral instructions are injected on every turn. Supports `"*"` wildcard fallback and account-scoped overrides under `channels.whatsapp.accounts.<id>.{groups,direct}`; account maps fully replace root maps (no deep merge), matching the existing `requireMention` pattern. Closes #7011. (#59553) Thanks @Bluetegu.
1212
- Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren.
13+
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
1314

1415
### Fixes
1516

extensions/speech-core/src/tts.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -932,9 +932,19 @@ export async function textToSpeechTelephony(params: {
932932
logVerbose(`TTS telephony: provider ${provider} skipped (${resolvedProvider.message})`);
933933
continue;
934934
}
935-
const synthesizeTelephony = resolvedProvider.provider.synthesizeTelephony as NonNullable<
936-
typeof resolvedProvider.provider.synthesizeTelephony
937-
>;
935+
const synthesizeTelephony = resolvedProvider.provider.synthesizeTelephony;
936+
if (!synthesizeTelephony) {
937+
const message = `${provider}: unsupported for telephony`;
938+
errors.push(message);
939+
attempts.push({
940+
provider,
941+
outcome: "skipped",
942+
reasonCode: "unsupported_for_telephony",
943+
error: message,
944+
});
945+
logVerbose(`TTS telephony: provider ${provider} skipped (${message})`);
946+
continue;
947+
}
938948
const synthesis = await synthesizeTelephony({
939949
text: params.text,
940950
cfg: params.cfg,

src/agents/model-selection-display.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,17 @@ describe("model-selection-display", () => {
8888
model: "anthropic/claude-haiku-4.5",
8989
});
9090
});
91+
92+
it("falls back to configured defaults when runtime session state is empty", () => {
93+
expect(
94+
resolveSessionInfoModelSelection({
95+
defaultProvider: "openai",
96+
defaultModel: "gpt-5.4",
97+
}),
98+
).toEqual({
99+
modelProvider: "openai",
100+
model: "gpt-5.4",
101+
});
102+
});
91103
});
92104
});

src/agents/model-selection-display.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export function resolveModelDisplayName(params: ModelDisplaySelectionParams): st
5656
type SessionInfoModelSelectionParams = {
5757
currentProvider?: string | null;
5858
currentModel?: string | null;
59+
defaultProvider?: string | null;
60+
defaultModel?: string | null;
5961
entryProvider?: string | null;
6062
entryModel?: string | null;
6163
overrideProvider?: string | null;
@@ -66,25 +68,27 @@ export function resolveSessionInfoModelSelection(params: SessionInfoModelSelecti
6668
modelProvider?: string;
6769
model?: string;
6870
} {
71+
const fallbackProvider = params.currentProvider ?? params.defaultProvider ?? undefined;
72+
const fallbackModel = params.currentModel ?? params.defaultModel ?? undefined;
73+
6974
if (params.entryProvider !== undefined || params.entryModel !== undefined) {
7075
return {
71-
modelProvider: params.entryProvider ?? params.currentProvider ?? undefined,
72-
model: params.entryModel ?? params.currentModel ?? undefined,
76+
modelProvider: params.entryProvider ?? fallbackProvider,
77+
model: params.entryModel ?? fallbackModel,
7378
};
7479
}
7580

7681
const overrideModel = params.overrideModel?.trim();
7782
if (overrideModel) {
7883
const overrideProvider = params.overrideProvider?.trim();
79-
const currentProvider = params.currentProvider ?? undefined;
8084
return {
81-
modelProvider: overrideProvider || currentProvider,
85+
modelProvider: overrideProvider || fallbackProvider,
8286
model: overrideModel,
8387
};
8488
}
8589

8690
return {
87-
modelProvider: params.currentProvider ?? undefined,
88-
model: params.currentModel ?? undefined,
91+
modelProvider: fallbackProvider,
92+
model: fallbackModel,
8993
};
9094
}

src/agents/openclaw-tools.ts

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
22
import { callGateway } from "../gateway/call.js";
3+
import { isEmbeddedMode } from "../infra/embedded-mode.js";
34
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
45
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
56
import type { GatewayMessageChannel } from "../utils/message-channel.js";
@@ -17,6 +18,7 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js";
1718
import { createCanvasTool } from "./tools/canvas-tool.js";
1819
import type { AnyAgentTool } from "./tools/common.js";
1920
import { createCronTool } from "./tools/cron-tool.js";
21+
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
2022
import { createGatewayTool } from "./tools/gateway-tool.js";
2123
import { createImageGenerateTool } from "./tools/image-generate-tool.js";
2224
import { createImageTool } from "./tools/image-tool.js";
@@ -227,22 +229,34 @@ export function createOpenClawTools(
227229
sandboxRoot: options?.sandboxRoot,
228230
workspaceDir,
229231
});
232+
const embedded = isEmbeddedMode();
233+
const effectiveCallGateway = embedded
234+
? createEmbeddedCallGateway()
235+
: openClawToolsDeps.callGateway;
230236
const tools: AnyAgentTool[] = [
231-
createCanvasTool({ config: options?.config }),
232-
nodesTool,
233-
createCronTool({
234-
agentSessionKey: options?.agentSessionKey,
235-
}),
236-
...(messageTool ? [messageTool] : []),
237+
...(embedded
238+
? []
239+
: [
240+
createCanvasTool({ config: options?.config }),
241+
nodesTool,
242+
createCronTool({
243+
agentSessionKey: options?.agentSessionKey,
244+
}),
245+
]),
246+
...(!embedded && messageTool ? [messageTool] : []),
237247
createTtsTool({
238248
agentChannel: options?.agentChannel,
239249
config: options?.config,
240250
}),
241251
...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]),
242-
createGatewayTool({
243-
agentSessionKey: options?.agentSessionKey,
244-
config: options?.config,
245-
}),
252+
...(embedded
253+
? []
254+
: [
255+
createGatewayTool({
256+
agentSessionKey: options?.agentSessionKey,
257+
config: options?.config,
258+
}),
259+
]),
246260
createAgentsListTool({
247261
agentSessionKey: options?.agentSessionKey,
248262
requesterAgentIdOverride: options?.requesterAgentIdOverride,
@@ -260,39 +274,43 @@ export function createOpenClawTools(
260274
agentSessionKey: options?.agentSessionKey,
261275
sandboxed: options?.sandboxed,
262276
config: resolvedConfig,
263-
callGateway: openClawToolsDeps.callGateway,
277+
callGateway: effectiveCallGateway,
264278
}),
265279
createSessionsHistoryTool({
266280
agentSessionKey: options?.agentSessionKey,
267281
sandboxed: options?.sandboxed,
268282
config: resolvedConfig,
269-
callGateway: openClawToolsDeps.callGateway,
270-
}),
271-
createSessionsSendTool({
272-
agentSessionKey: options?.agentSessionKey,
273-
agentChannel: options?.agentChannel,
274-
sandboxed: options?.sandboxed,
275-
config: resolvedConfig,
276-
callGateway: openClawToolsDeps.callGateway,
283+
callGateway: effectiveCallGateway,
277284
}),
285+
...(embedded
286+
? []
287+
: [
288+
createSessionsSendTool({
289+
agentSessionKey: options?.agentSessionKey,
290+
agentChannel: options?.agentChannel,
291+
sandboxed: options?.sandboxed,
292+
config: resolvedConfig,
293+
callGateway: openClawToolsDeps.callGateway,
294+
}),
295+
createSessionsSpawnTool({
296+
agentSessionKey: options?.agentSessionKey,
297+
agentChannel: options?.agentChannel,
298+
agentAccountId: options?.agentAccountId,
299+
agentTo: options?.agentTo,
300+
agentThreadId: options?.agentThreadId,
301+
agentGroupId: options?.agentGroupId,
302+
agentGroupChannel: options?.agentGroupChannel,
303+
agentGroupSpace: options?.agentGroupSpace,
304+
agentMemberRoleIds: options?.agentMemberRoleIds,
305+
sandboxed: options?.sandboxed,
306+
requesterAgentIdOverride: options?.requesterAgentIdOverride,
307+
workspaceDir: spawnWorkspaceDir,
308+
}),
309+
]),
278310
createSessionsYieldTool({
279311
sessionId: options?.sessionId,
280312
onYield: options?.onYield,
281313
}),
282-
createSessionsSpawnTool({
283-
agentSessionKey: options?.agentSessionKey,
284-
agentChannel: options?.agentChannel,
285-
agentAccountId: options?.agentAccountId,
286-
agentTo: options?.agentTo,
287-
agentThreadId: options?.agentThreadId,
288-
agentGroupId: options?.agentGroupId,
289-
agentGroupChannel: options?.agentGroupChannel,
290-
agentGroupSpace: options?.agentGroupSpace,
291-
agentMemberRoleIds: options?.agentMemberRoleIds,
292-
sandboxed: options?.sandboxed,
293-
requesterAgentIdOverride: options?.requesterAgentIdOverride,
294-
workspaceDir: spawnWorkspaceDir,
295-
}),
296314
createSubagentsTool({
297315
agentSessionKey: options?.agentSessionKey,
298316
}),

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@mariozechner/pi-coding-agent";
1010
import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
1111
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
12+
import { isEmbeddedMode } from "../../../infra/embedded-mode.js";
1213
import { formatErrorMessage } from "../../../infra/errors.js";
1314
import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js";
1415
import { getMachineDisplayName } from "../../../infra/machine-name.js";
@@ -619,11 +620,19 @@ export async function runEmbeddedAttempt(
619620
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
620621
previousSignature: params.bootstrapPromptWarningSignature,
621622
});
622-
const workspaceNotes = hookAdjustedBootstrapFiles.some(
623-
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
624-
)
625-
? ["Reminder: commit your changes in this workspace after edits."]
626-
: undefined;
623+
const workspaceNotes: string[] = [];
624+
if (
625+
hookAdjustedBootstrapFiles.some(
626+
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
627+
)
628+
) {
629+
workspaceNotes.push("Reminder: commit your changes in this workspace after edits.");
630+
}
631+
if (isEmbeddedMode()) {
632+
workspaceNotes.push(
633+
"Running in local embedded mode (no gateway). Most tools work locally. Gateway-dependent tools (canvas, nodes, cron, message, sessions_send, sessions_spawn, gateway) are unavailable. Subagent kill/steer require a gateway. Do not attempt to read gateway-specific files such as sessions.json, gateway.log, or gateway.pid.",
634+
);
635+
}
627636

628637
const { defaultAgentId } = resolveSessionAgentIds({
629638
sessionKey: params.sessionKey,
@@ -876,7 +885,7 @@ export async function runEmbeddedAttempt(
876885
skillsPrompt: effectiveSkillsPrompt,
877886
docsPath: docsPath ?? undefined,
878887
ttsHint,
879-
workspaceNotes,
888+
workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined,
880889
reactionGuidance,
881890
promptMode: effectivePromptMode,
882891
acpEnabled: params.config?.acp?.enabled !== false,

src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,30 @@ describe("handleAgentEnd", () => {
186186
});
187187
});
188188

189+
it("omits raw HTML auth bodies from consoleMessage for HTML 403 auth failures", async () => {
190+
const ctx = createContext({
191+
role: "assistant",
192+
stopReason: "error",
193+
provider: "openai-codex",
194+
model: "gpt-5.4",
195+
errorMessage: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
196+
content: [{ type: "text", text: "" }],
197+
});
198+
199+
await handleAgentEnd(ctx);
200+
201+
const warnMeta = vi.mocked(ctx.log.warn).mock.calls[0]?.[1];
202+
expect(warnMeta).toMatchObject({
203+
providerRuntimeFailureKind: "auth_html_403",
204+
rawErrorPreview: "403 <!DOCTYPE html><html><body>Access denied</body></html>",
205+
error:
206+
"Authentication failed with an HTML 403 response from the provider. Re-authenticate and verify your provider account access.",
207+
});
208+
const consoleMsg = typeof warnMeta?.consoleMessage === "string" ? warnMeta.consoleMessage : "";
209+
expect(consoleMsg).not.toContain("rawError=");
210+
expect(consoleMsg).not.toContain("<html>");
211+
});
212+
189213
it("keeps non-error run-end logging on debug only", async () => {
190214
const ctx = createContext(undefined);
191215

src/agents/pi-embedded-subscribe.handlers.lifecycle.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise<
8686
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";
8787
const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown";
8888
const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview);
89-
const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : "";
89+
const shouldSuppressRawErrorConsoleSuffix =
90+
observedError.providerRuntimeFailureKind === "auth_html_403" ||
91+
observedError.providerRuntimeFailureKind === "auth_scope" ||
92+
observedError.providerRuntimeFailureKind === "auth_refresh";
93+
const rawErrorConsoleSuffix =
94+
safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix
95+
? ` rawError=${safeRawErrorPreview}`
96+
: "";
9097
ctx.log.warn("embedded run agent end", {
9198
event: "embedded_run_agent_end",
9299
tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"],

0 commit comments

Comments
 (0)