Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/claude-sdk-cli/src/controller/CommandIntentExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import { IConversationState } from '../model/ConversationState.js';
import { ISystemIdentity } from '../model/ISystemIdentity.js';
import { ModelSettings } from '../model/ModelSettings.js';
import { StatusState } from '../model/StatusState.js';
import { ToolModeState } from '../model/ToolModeState.js';
import { IWorkingDirectory } from '../model/WorkingDirectory.js';

export type CommandIntent = 'pasteText' | 'pasteFile' | 'pasteImage' | 'removeAttachment' | 'togglePreview' | 'newSession' | 'selectPrev' | 'selectNext' | 'enterModelSubMode' | 'cycleThinking' | 'cycleEffort' | 'openModelEditor' | 'submitModel' | 'enterCdSubMode' | 'openCdEditor' | 'submitCd';
export type CommandIntent = 'pasteText' | 'pasteFile' | 'pasteImage' | 'removeAttachment' | 'togglePreview' | 'newSession' | 'selectPrev' | 'selectNext' | 'enterModelSubMode' | 'cycleThinking' | 'cycleEffort' | 'openModelEditor' | 'submitModel' | 'enterCdSubMode' | 'openCdEditor' | 'submitCd' | 'cycleToolMode';

/** Deliberate-path test for the missing-file chip (was AppLayout.isLikelyPath). */
function isLikelyPath(s: string): boolean {
Expand Down Expand Up @@ -54,6 +55,7 @@ export class CommandIntentExecutor {
@dependsOn(IFileSystem) private readonly fs!: IFileSystem;
@dependsOn(IWorkingDirectory) private readonly workingDirectory!: IWorkingDirectory;
@dependsOn(IModelCatalog) private readonly modelCatalog!: IModelCatalog;
@dependsOn(ToolModeState) private readonly toolModeState!: ToolModeState;

public async execute(intent: CommandIntent): Promise<void> {
try {
Expand Down Expand Up @@ -121,6 +123,9 @@ export class CommandIntentExecutor {
case 'submitCd':
this.#submitCd();
return;
case 'cycleToolMode':
this.toolModeState.cycle();
return;
}
} catch {
// Fire-and-forget: a failed clipboard read leaves state untouched.
Expand Down
1 change: 1 addition & 0 deletions apps/claude-sdk-cli/src/controller/CommandKeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const PRIMARY_COMMAND_BINDINGS: ReadonlyMap<string, CommandIntent> = new
['c', 'enterCdSubMode'],
['n', 'newSession'],
['m', 'enterModelSubMode'],
['o', 'cycleToolMode'],
]);

/** cd sub-menu command set: d opens the path editor. One entry by design — the
Expand Down
10 changes: 10 additions & 0 deletions apps/claude-sdk-cli/src/model/StatusState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import EventEmitter from 'node:events';
import type { SdkMessageUsage, ThinkingEffort } from '@shellicar/claude-sdk';
import type { ToolMode } from './ToolModeState.js';

type StatusStateEvents = {
change: [];
Expand Down Expand Up @@ -36,6 +37,7 @@ export class StatusState {
#showConversationId = false;
#thinkingOverride: 'on' | 'off' | null = null;
#effortOverride: ThinkingEffort | null = null;
#toolMode: ToolMode = 'normal';
#cwdBasename: string;
readonly #emitter = new EventEmitter<StatusStateEvents>();

Expand Down Expand Up @@ -81,6 +83,9 @@ export class StatusState {
public get effortOverride(): ThinkingEffort | null {
return this.#effortOverride;
}
public get toolMode(): ToolMode {
return this.#toolMode;
}
public get cwdBasename(): string {
return this.#cwdBasename;
}
Expand Down Expand Up @@ -135,6 +140,11 @@ export class StatusState {
this.#emitter.emit('change');
}

public setToolMode(mode: ToolMode): void {
this.#toolMode = mode;
this.#emitter.emit('change');
}

/**
* Replace the running totals wholesale from a derived snapshot. Called when
* the figures are re-derived from the audit for the current conversation id
Expand Down
34 changes: 34 additions & 0 deletions apps/claude-sdk-cli/src/model/ToolModeState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { dependsOn } from '@shellicar/core-di';
import { StatusState } from './StatusState.js';

/**
* The tool-availability mode command mode cycles through `o`: `normal` offers every tool the
* config/az-account state would otherwise allow; `readOnly` narrows the wire tool list to only
* `read`/`ephemeral.read` operations (see `isReadOperation`) — Claude can look, not act, useful
* for "let's agree what to do before you go do it"; `noTools` narrows it to nothing, for "stop
* calling tools and talk to me." Cycling is a session-only concern: it does not persist across a
* restart the way the tool-availability reminder does.
*/
export type ToolMode = 'normal' | 'readOnly' | 'noTools';

const TOOL_MODE_CYCLE: readonly ToolMode[] = ['normal', 'readOnly', 'noTools'];

export abstract class ToolModeState {
public abstract get mode(): ToolMode;
public abstract cycle(): void;
}

export class ToolModeSettings extends ToolModeState {
@dependsOn(StatusState) private readonly statusState!: StatusState;
#mode: ToolMode = 'normal';

public get mode(): ToolMode {
return this.#mode;
}

public cycle(): void {
const idx = TOOL_MODE_CYCLE.indexOf(this.#mode);
this.#mode = TOOL_MODE_CYCLE[(idx + 1) % TOOL_MODE_CYCLE.length] ?? 'normal';
this.statusState.setToolMode(this.#mode);
}
}
8 changes: 7 additions & 1 deletion apps/claude-sdk-cli/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,18 @@ export function getPermission(tool: ToolCall, allTools: readonly PermissionTool[
if (operation === 'escalate') {
return PermissionAction.Ask;
}
// 'ephemeral.read'/'ephemeral.write' have no zone concept of their own either — no marked paths
// (Ref, SearchHistory, ReadHistory carry none), so they always land in the 'default' zone below.
// The matrix itself only knows read/write/delete, so an ephemeral operation maps onto its plain
// counterpart for approve/ask/deny purposes; the 'ephemeral.' half of the name only matters to
// read-only-mode gating (see isReadOperation), never to this matrix.
const matrixOperation = operation === 'ephemeral.read' ? 'read' : operation === 'ephemeral.write' ? 'write' : operation;
// The marked paths in tool.input were already replaced in place by the SDK; locate them via the
// schema marker and read the (normalised) values. Any path outside cwd escalates to the outside
// zone, matching the pipe's Math.max escalation across steps.
const paths = definition.input_schema ? collectPaths(definition.input_schema, tool.input) : [];
const zone: 'default' | 'outside' = paths.some((p) => !isInsideCwd(p, cwd)) ? 'outside' : 'default';
return matrix[zone][operation];
return matrix[zone][matrixOperation];
}

/** Names every tool with no definition — the top-level tool, or, for a pipe, each unfound step.
Expand Down
5 changes: 4 additions & 1 deletion apps/claude-sdk-cli/src/runAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type RunAgentStores = {
primaryViewState: IPrimaryViewState;
};

export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, stores: RunAgentStores, transformToolResult: TransformToolResult, abortController: AbortController, gitDelta?: string, skillDelta?: string | null, cwdDelta?: string | null): Promise<void> {
export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, stores: RunAgentStores, transformToolResult: TransformToolResult, abortController: AbortController, gitDelta?: string, skillDelta?: string | null, cwdDelta?: string | null, toolsDelta?: string | null): Promise<void> {
const { conversationState, toolApprovalState, editorState, primaryViewState } = stores;

// On resume there is no new user message: don't open a prompt block.
Expand All @@ -97,6 +97,9 @@ export async function runAgent(queryRunner: QueryRunner, input: RunAgentInput, s
if (cwdDelta) {
reminders.push({ text: cwdDelta, persisted: true, position: 'leading' });
}
if (toolsDelta) {
reminders.push({ text: toolsDelta, persisted: true, position: 'leading' });
}
if (gitDelta) {
reminders.push({ text: gitDelta, persisted: false, position: 'trailing' });
}
Expand Down
24 changes: 23 additions & 1 deletion apps/claude-sdk-cli/src/setup/ConfigDisabledToolsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader';
import { IDisabledToolsProvider } from '@shellicar/claude-sdk';
import { IDisabledToolsProvider, isReadOperation } from '@shellicar/claude-sdk';
import { AZ_CLI_TOOL_NAME, ESCALATED_AZ_CLI_TOOL_NAME } from '@shellicar/claude-sdk-tools/Az';
import { ADO_PR_TOOL_NAMES } from '@shellicar/claude-sdk-tools/AzureDevOps';
import { dependsOn } from '@shellicar/core-di';
import { ToolModeState } from '../model/ToolModeState.js';
import { AppToolsService } from './AppToolsService.js';

export class ConfigDisabledToolsProvider extends IDisabledToolsProvider {
@dependsOn(ConfigLoader)
public configLoader!: ConfigLoader<any>;
@dependsOn(ToolModeState)
public toolModeState!: ToolModeState;
@dependsOn(AppToolsService)
public appTools!: AppToolsService;

/** Read fresh on every access (see `IDisabledToolsProvider`): whether any account currently has a
* reader/holder identity configured is live config, so `AzCli`/`EscalatedAzCli`/the
Expand All @@ -28,6 +34,22 @@ export class ConfigDisabledToolsProvider extends IDisabledToolsProvider {
disabled.add(name);
}
}

// The tool-availability mode (see ToolModeState) narrows the wire list further, on top of
// whatever config/az-account state already disabled above — never in place of it. 'readOnly'
// keeps only read/ephemeral.read tools; 'noTools' keeps none.
const mode = this.toolModeState.mode;
if (mode === 'noTools') {
for (const tool of this.appTools.tools) {
disabled.add(tool.name);
}
} else if (mode === 'readOnly') {
for (const tool of this.appTools.tools) {
if (!isReadOperation(tool.operation)) {
disabled.add(tool.name);
}
}
}
return disabled;
}
}
107 changes: 107 additions & 0 deletions apps/claude-sdk-cli/src/setup/ToolAvailabilityTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Anthropic } from '@anthropic-ai/sdk';

const ENABLED_HEADER = 'Enabled tools:';
const DISABLED_HEADER = 'Disabled tools:';

function isSystemReminderText(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith('<system-reminder>') && trimmed.endsWith('</system-reminder>');
}

/** Parses one previously-emitted reminder's delta back into name lists, or null when `text` isn't
* one of ours. Tool names never contain '.' or ',', so splitting on the header/period/comma
* boundaries is unambiguous. */
function parseDelta(text: string): { enabled: string[]; disabled: string[] } | null {
if (!isSystemReminderText(text)) {
return null;
}
const inner = text.trim().slice('<system-reminder>'.length, -'</system-reminder>'.length).trim();
if (!inner.startsWith(ENABLED_HEADER) && !inner.startsWith(DISABLED_HEADER)) {
return null;
}
const enabledMatch = inner.match(/Enabled tools: ([^.]+)\./);
const disabledMatch = inner.match(/Disabled tools: ([^.]+)\./);
const splitNames = (s: string): string[] =>
s
.split(',')
.map((n) => n.trim())
.filter((n) => n.length > 0);
return {
enabled: enabledMatch ? splitNames(enabledMatch[1] ?? '') : [],
disabled: disabledMatch ? splitNames(disabledMatch[1] ?? '') : [],
};
}

function formatDelta(enabled: readonly string[], disabled: readonly string[]): string {
const parts: string[] = [];
if (enabled.length > 0) {
parts.push(`${ENABLED_HEADER} ${[...enabled].sort().join(', ')}.`);
}
if (disabled.length > 0) {
parts.push(`${DISABLED_HEADER} ${[...disabled].sort().join(', ')}.`);
}
return parts.join(' ');
}

/**
* Tells the model which tools it currently has, as a delta rather than a repeated full list — a
* single tool flipping never re-announces the other 99.
*
* On the first call this process makes, the baseline is reconstructed by replaying every reminder
* this tracker ever emitted, in order, out of the persisted conversation history — so a restart
* loses nothing without the tracker needing its own persisted state. Finding nothing (fresh
* conversation, or history compacted past every prior reminder) reconstructs an empty baseline,
* which is not a special case: diffing the live set against empty naturally produces a full
* "Enabled tools:" opener with nothing in "Disabled tools:".
*
* Every call after the first behaves like `CwdTracker`/`SkillCatalogueTracker`: an in-memory diff
* against the previous call's result, updated (and only emitted) when something actually changed;
* `messages` is ignored once seeded. Call this once per turn, with the live set as computed at the
* point a message is actually about to be built and sent — never speculatively, and never advanced
* by an attempt that didn't land — so a cancel-and-resend recomputes the same diff against the same
* unmoved baseline rather than skipping or doubling it.
*/
export class ToolAvailabilityTracker {
#known: Set<string> | null = null;

#seedFromHistory(messages: readonly Anthropic.Beta.Messages.BetaMessageParam[]): Set<string> {
const known = new Set<string>();
for (const msg of messages) {
if (msg.role !== 'user' || !Array.isArray(msg.content)) {
continue;
}
for (const block of msg.content) {
if (block.type !== 'text') {
continue;
}
const delta = parseDelta(block.text);
if (delta == null) {
continue;
}
for (const name of delta.enabled) {
known.add(name);
}
for (const name of delta.disabled) {
known.delete(name);
}
}
}
return known;
}

/** Returns the delta reminder text for this query, or null when nothing changed. On the first
* call this process makes, the baseline is reconstructed by replaying `messages` (see class
* doc); every call after that diffs against the in-memory result of the previous call, and
* `messages` is ignored. Never advances state for a message that hasn't actually been sent —
* call this once, at the point a query is actually being built, not speculatively. */
public scanForDelta(messages: readonly Anthropic.Beta.Messages.BetaMessageParam[], liveEnabled: ReadonlySet<string>): string | null {
const previous = this.#known ?? this.#seedFromHistory(messages);
const enabled = [...liveEnabled].filter((name) => !previous.has(name));
const disabled = [...previous].filter((name) => !liveEnabled.has(name));
this.#known = new Set(liveEnabled);
if (enabled.length === 0 && disabled.length === 0) {
return null;
}
return formatDelta(enabled, disabled);
}
}
10 changes: 9 additions & 1 deletion apps/claude-sdk-cli/src/setup/TurnCoordinator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ConfigLoader } from '@shellicar/claude-core/Config/ConfigLoader';
import { IDurableConfigProvider, QueryRunner } from '@shellicar/claude-sdk';
import { IConversation, IDisabledToolsProvider, IDurableConfigProvider, QueryRunner } from '@shellicar/claude-sdk';
import { dependsOn } from '@shellicar/core-di';
import { ClaudeMdLoader } from '../ClaudeMdLoader.js';
import { IConvChangePublisher } from '../conv/ConvChangePublisher.js';
Expand All @@ -22,6 +22,7 @@ import { CwdTracker } from './CwdTracker.js';
import { ModelOverrides } from './ModelOverrides.js';
import { ISdkEventBridge } from './SdkEventBridge.js';
import { SkillCatalogueTracker } from './SkillCatalogueTracker.js';
import { ToolAvailabilityTracker } from './ToolAvailabilityTracker.js';

/** The coordinator's contract; register abstract→concrete and depend on the abstract (DI rule). */
export abstract class ITurnCoordinator {
Expand Down Expand Up @@ -62,6 +63,9 @@ export class TurnCoordinator extends ITurnCoordinator {
@dependsOn(GitStateMonitor) private readonly gitMonitor!: GitStateMonitor;
@dependsOn(SkillCatalogueTracker) private readonly skillTracker!: SkillCatalogueTracker;
@dependsOn(CwdTracker) private readonly cwdTracker!: CwdTracker;
@dependsOn(ToolAvailabilityTracker) private readonly toolAvailabilityTracker!: ToolAvailabilityTracker;
@dependsOn(IDisabledToolsProvider) private readonly disabledToolsProvider!: IDisabledToolsProvider;
@dependsOn(IConversation) private readonly conversation!: IConversation;
@dependsOn(QueryRunner) private readonly queryRunner!: QueryRunner;
@dependsOn(IConversationState) private readonly conversationState!: IConversationState;
@dependsOn(IToolApprovalState) private readonly toolApprovalState!: IToolApprovalState;
Expand Down Expand Up @@ -130,6 +134,9 @@ export class TurnCoordinator extends ITurnCoordinator {
// reminder on the user message. First scan of the process records the baseline and returns null.
const skillDelta = await this.skillTracker.scanForDelta();
const cwdDelta = this.cwdTracker.scanForDelta();
const disabledNames = this.disabledToolsProvider.disabledTools;
const liveEnabledNames = new Set(this.appTools.tools.filter((t) => !disabledNames.has(t.name)).map((t) => t.name));
const toolsDelta = this.toolAvailabilityTracker.scanForDelta(this.conversation.messages, liveEnabledNames);
const agentInput = buildRunAgentInput(userInput);
await runAgent(
this.queryRunner,
Expand All @@ -145,6 +152,7 @@ export class TurnCoordinator extends ITurnCoordinator {
gitDelta,
skillDelta,
cwdDelta,
toolsDelta,
);
await this.gitMonitor.takeSnapshot();

Expand Down
5 changes: 5 additions & 0 deletions apps/claude-sdk-cli/src/setup/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import { StreamInterruptNotice } from '../model/StreamInterruptNotice.js';
import { SystemIdentity } from '../model/SystemIdentity.js';
import { ITerminalState, TerminalState } from '../model/TerminalState.js';
import { IToolApprovalState, ToolApprovalState } from '../model/ToolApprovalState.js';
import { ToolModeSettings, ToolModeState } from '../model/ToolModeState.js';
import { TurnClock } from '../model/TurnClock.js';
import { IWorkingDirectory, WorkingDirectory } from '../model/WorkingDirectory.js';
import { DatabaseFactory } from '../persistence/DatabaseFactory.js';
Expand Down Expand Up @@ -157,6 +158,7 @@ import { IShutdownCoordinator, ShutdownCoordinator } from './ShutdownCoordinator
import { IShutdownSequence, ShutdownSequence } from './ShutdownSequence.js';
import { SkillCatalogueTracker } from './SkillCatalogueTracker.js';
import { SkillGateProvider } from './SkillGateProvider.js';
import { ToolAvailabilityTracker } from './ToolAvailabilityTracker.js';
import { ITurnCoordinator, TurnCoordinator } from './TurnCoordinator.js';
import { IWorkingDirectoryMoveHandler, WorkingDirectoryMoveHandler } from './WorkingDirectoryMoveHandler.js';

Expand Down Expand Up @@ -393,6 +395,7 @@ export function buildContainer(options: ContainerOptions): IServiceCollection {
services.register(DurableConfigFactory).as(IDurableConfigProvider);
services.register(SkillCatalogueTracker).asSelf();
services.register(CwdTracker).asSelf();
services.register(ToolAvailabilityTracker).asSelf();
// SdkChannel and ISdkMessagePublisher share identity from this one register() call.
services.register(SdkChannel).asSelf().as(ISdkMessagePublisher);
services.register(ConsumerChannel).asSelf();
Expand Down Expand Up @@ -422,6 +425,8 @@ export function buildContainer(options: ContainerOptions): IServiceCollection {
services.register(NodeSipsBridge).asSelf().as(SipsBridge);
// ModelOverrides and ModelSettings share identity from this one register() call.
services.register(ModelOverrides).asSelf().as(ModelSettings);
// ToolModeSettings and ToolModeState share identity from this one register() call.
services.register(ToolModeSettings).asSelf().as(ToolModeState);

// --- state stores ---
services
Expand Down
Loading
Loading