Skip to content
Open
85 changes: 83 additions & 2 deletions packages/agent-connector/src/adapters/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
const { buildPhaseGateDirective } = require('./workspace-prompt');
const { defaultAgentWorkdir } = require('../paths');
const {
REASON,
Expand Down Expand Up @@ -75,6 +76,11 @@ class BaseAdapter {
this._processedIds = new Set();
this._titledSessions = new Set();
this._mode = 'execute';
// Per-channel, per-message mode override set by the clarification phase
// gate (see _modeFor). Keyed by channel — the same channel is processed
// serially, but different channels run in parallel, so a single scalar
// would leak one channel's gate onto another.
this._modeOverrides = {};
this._lastControlId = null;
this._controlWake = null;
// Per-channel task tracking for parallel execution
Expand Down Expand Up @@ -699,7 +705,53 @@ class BaseAdapter {
// Channel dispatch
// ------------------------------------------------------------------

/**
* Role this agent plays in an active clarification phase, from the routed
* message's metadata. Returns null when the channel isn't gated.
*
* 'plan' → the backend downgraded this wake-up (target_modes): answer,
* don't build
* 'owner' → this agent owns the phase and is the one who advances it
* 'member'→ phase is active, this agent is neither of the above
*/
_phaseRole(msg) {
const meta = (msg && msg.metadata) || {};
if (meta.phase !== 'clarifying') return null;
const modes = meta.target_modes || {};
if (modes[this.agentName] === 'plan') return 'plan';
if (meta.phase_owner && meta.phase_owner === this.agentName) return 'owner';
return 'member';
}

/**
* Append the clarification-phase directive to a routed message.
*
* The backend gate decides who may be woken; this is what stops a woken
* builder from implementing against an unsettled spec. Done here rather
* than per adapter so every adapter type is covered by one code path, and
* appended (not prepended) so channel auto-titling still sees the user's
* own words first.
*/
_applyPhaseGate(msg) {
const role = this._phaseRole(msg);
if (!role) return msg;
const directive = buildPhaseGateDirective({
role,
owner: (msg.metadata || {}).phase_owner,
endpoint: this.endpoint,
workspaceId: this.workspaceId,
channelName: msg.sessionId || this.channelName,
});
if (!directive) return msg;
this._log(`Phase gate active (${role}) for message ${msg.messageId || '?'}`);
return { ...msg, content: `${msg.content || ''}${directive}` };
}

async _dispatchMessage(msg) {
// Carry the phase directive on the message itself so a queued message
// still holds the constraint it arrived under when it is finally run.
msg = this._applyPhaseGate(msg);

// Use sessionId only if it looks like a channel, not an agent target
let channel = this.channelName || 'general';
if (msg.sessionId && !msg.sessionId.startsWith('openagents:') && !msg.sessionId.startsWith('agent:')) {
Expand Down Expand Up @@ -744,10 +796,39 @@ class BaseAdapter {
return true;
}

/**
* The mode this agent must run in for work on `channel` right now: the
* agent's own mode, unless the message being handled was gated into PLAN
* by the clarification phase. Adapters that enforce plan mode at the
* runtime level (read-only tools, no writes) should read this instead of
* `this._mode` so a gated wake-up genuinely cannot build.
*/
_modeFor(channel) {
return this._modeOverrides[channel] || this._mode;
}

/**
* Run one message with its phase-gate mode override in effect. The
* override is per channel and cleared afterwards, so it applies to exactly
* the message it arrived with.
*/
async _runMessage(channel, msg) {
if (this._phaseRole(msg) === 'plan') {
this._modeOverrides[channel] = 'plan';
} else {
delete this._modeOverrides[channel];
}
try {
await this._handleMessage(msg);
} finally {
delete this._modeOverrides[channel];
}
}

async _channelWorker(channel, msg) {
this._channelBusy.add(channel);
try {
await this._handleMessage(msg);
await this._runMessage(channel, msg);
} catch (e) {
this._log(`Error in channel worker for ${channel}: ${e.message}`);
try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
Expand All @@ -762,7 +843,7 @@ class BaseAdapter {
try { await this.sendStatus(channel, 'processing queued message', { queue_id: nextMsg._queueId, queue_status: 'processed' }); } catch {}
}
try {
await this._handleMessage(nextMsg);
await this._runMessage(channel, nextMsg);
} catch (e) {
this._log(`Error processing queued message in ${channel}: ${e.message}`);
try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
Expand Down
17 changes: 10 additions & 7 deletions packages/agent-connector/src/adapters/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ class ClaudeAdapter extends BaseAdapter {
agentName: this.agentName,
workspaceId: this.workspaceId,
channelName,
mode: this._mode,
mode: this._modeFor(channelName),
browserEnabled,
toolMode: this.toolMode,
decisionLog,
Expand Down Expand Up @@ -526,7 +526,7 @@ class ClaudeAdapter extends BaseAdapter {
* Skills mode: write a SKILL.md file and allow Bash + curl for workspace ops.
*/
_buildSkillsCmd(cmd, channelName) {
if (this._mode === 'plan') {
if (this._modeFor(channelName) === 'plan') {
cmd.push('--permission-mode', 'plan');
cmd.push('--allowedTools', 'Read', 'Glob', 'Grep', 'Bash');
} else {
Expand Down Expand Up @@ -607,7 +607,7 @@ class ClaudeAdapter extends BaseAdapter {
mcpTools.push(`${pfx}workspace_get_todos`, `${pfx}workspace_list_timers`, `${pfx}workspace_list_routines`);
mcpWriteTools.push(`${pfx}workspace_put_todos`, `${pfx}workspace_create_timer`, `${pfx}workspace_cancel_timer`, `${pfx}workspace_create_routine`, `${pfx}workspace_cancel_routine`);

if (this._mode === 'plan') {
if (this._modeFor(channelName) === 'plan') {
cmd.push('--permission-mode', 'plan');
cmd.push('--allowedTools', ...mcpTools, 'Read', 'Glob', 'Grep');
} else {
Expand Down Expand Up @@ -1028,7 +1028,10 @@ class ClaudeAdapter extends BaseAdapter {
* and the fresh-spawn path so their behavior can never drift.
*/
_composeFinalResponse(pp) {
if (this._mode === 'plan') {
// The spawn-time mode, not the current one: a phase-gated turn ran in
// plan mode even though the agent's own mode is execute, and its plan
// file is what the user must see.
if ((pp.spawnMode || this._mode) === 'plan') {
try {
const planDir = path.join(this.workingDir || defaultAgentWorkdir(this.agentName), '.claude', 'plans');
if (fs.existsSync(planDir)) {
Expand Down Expand Up @@ -1174,14 +1177,14 @@ class ClaudeAdapter extends BaseAdapter {
// never be reused. This check is deliberately independent of the
// decision fingerprint: a failed decision fetch must not keep a
// read-only plan process serving execute requests.
const modeStale = existingPP.spawnMode !== this._mode;
const modeStale = existingPP.spawnMode !== this._modeFor(msgChannel);
const decisionsStale = decisionHash !== null && existingPP.decisionHash !== decisionHash;
if (modeStale || decisionsStale) {
// Kill it and fall through to a fresh spawn: --resume keeps the
// conversation (it lives in the CLI transcript), while the new spawn
// carries the current mode and re-pins the current decisions.
this._log(modeStale
? `Mode changed to ${this._mode} for ${msgChannel} — respawning with resume`
? `Mode changed to ${this._modeFor(msgChannel)} for ${msgChannel} — respawning with resume`
: `Decision log changed for ${msgChannel} — respawning with resume to re-pin decisions`);
await this._killPersistentProc(msgChannel);
} else {
Expand Down Expand Up @@ -1304,7 +1307,7 @@ class ClaudeAdapter extends BaseAdapter {
pp.decisionHash = decisionLogOpt
? decisionFingerprint(decisionLogOpt.entryId, decisionLogOpt.content)
: decisionFingerprint(null, null);
pp.spawnMode = this._mode;
pp.spawnMode = this._modeFor(msgChannel);
this._log(`Spawned persistent process for ${msgChannel} (attempt ${attempt + 1})`);

const result = await this._sendToPersistentProc(pp, effectiveContent);
Expand Down
66 changes: 66 additions & 0 deletions packages/agent-connector/src/adapters/workspace-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,71 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
return sections.join('\n');
}

/**
* Per-message directive for a channel whose requirement is still being
* clarified (the backend's phase gate, see `_apply_phase_gate` in
* workspace_mod.py).
*
* The gate already decides WHO gets woken; this is what makes the wake-up
* safe: an agent consulted mid-clarification answers the question instead of
* starting to build against a specification that isn't settled yet.
*
* `role` comes from the routed message's metadata:
* 'owner' → this agent holds the floor (phase owner / channel master)
* 'plan' → this agent was @mentioned but must not build (target_modes)
* other → phase is active but this agent is neither; just state the phase
*
* Returns '' when there is nothing to say, so callers can concatenate
* unconditionally.
*/
function buildPhaseGateDirective({ role, owner, endpoint, workspaceId, channelName } = {}) {
if (!role) return '';
const who = owner || 'the phase owner';

if (role === 'owner') {
const base = endpoint ? String(endpoint).replace(/\/+$/, '') : '';
const patch = base && workspaceId && channelName
? ` (no such tool? PATCH ${base}/v1/workspaces/${workspaceId}/channels/${channelName} ` +
'with {"phase":"building"} and your X-Workspace-Token header)'
: '';
return (
'\n\n---\n' +
'[Workspace phase: CLARIFYING — you own this phase]\n' +
'The requirement in this channel is not settled yet, and settling it is ' +
'your job. Ask what is still open, confirm your understanding, and record ' +
'what the user agrees to. While this phase is active no other agent can ' +
'start implementing — they can only be consulted.\n' +
'Once the user has confirmed the requirement, advance the phase: call ' +
`\`workspace_set_phase\` with phase="building"${patch}. ` +
'Nobody can start building until you do, so do not leave it behind — but ' +
'do not advance it on your own judgement either; wait for the user.\n'
);
}

if (role === 'plan') {
return (
'\n\n---\n' +
'[Workspace phase: CLARIFYING — answer in PLAN mode]\n' +
'You have been pulled in while the requirement in this channel is still ' +
`being clarified by ${who}. Answer what was actually asked: feasibility, ` +
'risks, options, rough effort, or questions of your own that need ' +
'answering before this can be built.\n' +
'Do NOT start the work — no code, no file edits, no commands that change ' +
'anything. The specification is not final, so anything built now would be ' +
`built on guesses. ${who} advances the phase once the requirement is ` +
'confirmed, and implementation starts then.\n'
);
}

return (
'\n\n---\n' +
`[Workspace phase: CLARIFYING — owned by ${who}]\n` +
'The requirement in this channel is still being clarified. Keep your reply ' +
'to what helps settle it, and leave implementation until the phase ' +
'advances.\n'
);
}

/**
* Guardrails shared across all adapter prompt builders.
*/
Expand Down Expand Up @@ -962,6 +1027,7 @@ module.exports = {
buildBrowserDirective,
buildCollaborationPrompt,
buildModePrompt,
buildPhaseGateDirective,
buildGuardrails,
buildApiSkillsPrompt,
buildClaudeMcpToolBlock,
Expand Down
33 changes: 33 additions & 0 deletions packages/agent-connector/src/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ function buildToolDefs(disabledModules) {
required: ['status'],
},
},
{
name: 'workspace_set_phase',
description:
'Set this channel\'s requirement phase. "clarifying" keeps the floor with the ' +
'phase owner so no agent starts building on an unsettled spec (others can only be ' +
'consulted, and only in plan mode); "building" releases the gate once the user has ' +
'confirmed the requirement; "open" turns the gate off entirely. Only advance to ' +
'"building" after the user confirms — never on your own judgement.',
inputSchema: {
type: 'object',
properties: {
phase: {
type: 'string',
enum: ['open', 'clarifying', 'building'],
description: 'Phase to set for the current channel',
},
owner: {
type: 'string',
description: 'Agent that owns the clarifying phase (defaults to the channel master)',
},
},
required: ['phase'],
},
},
];

// -- Files module --
Expand Down Expand Up @@ -636,6 +660,15 @@ class McpServer {
return text(`Status updated: ${args.status}`);
}

case 'workspace_set_phase': {
const result = await this.ws.setChannelPhase(
this.workspaceId, this.channelName, this.token,
{ phase: args.phase, owner: args.owner },
);
const owner = result.phaseOwner ? ` (owner: ${result.phaseOwner})` : '';
return text(`Channel phase set to "${result.phase || args.phase}"${owner}.`);
}

// ── Files ──

case 'workspace_list_files': {
Expand Down
23 changes: 22 additions & 1 deletion packages/agent-connector/src/workspace-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,12 +389,33 @@ class WorkspaceClient {
titleManuallySet: result.titleManuallySet || false,
resumeFrom: result.resumeFrom || null,
status: result.status || 'active',
phase: result.phase || 'open',
phaseOwner: result.phaseOwner || null,
};
} catch {
return { sessionId: channelName, title: channelName, status: 'active' };
return { sessionId: channelName, title: channelName, status: 'active', phase: 'open', phaseOwner: null };
}
}

/**
* Set a channel's clarification phase via PATCH
* /v1/workspaces/{id}/channels/{name}.
*
* Unlike updateSession this one throws: it backs an agent-facing tool, and
* silently failing to advance the phase would leave the thread gated with
* the agent believing it had opened it.
*/
async setChannelPhase(workspaceId, channelName, token, { phase, owner } = {}) {
const body = { phase };
if (owner !== undefined) body.phase_owner = owner;
const data = await this._patch(
`/v1/workspaces/${workspaceId}/channels/${channelName}`,
body,
this._wsHeaders(token),
);
return (data && (data.data || data)) || {};
}

/**
* Update session/channel info via PATCH /v1/workspaces/{id}/channels/{name}.
*/
Expand Down
Loading
Loading