MainSequence login, repositories, projects, MCP, agents and activity inside Claude Desktop Code.

MainSequence for Claude Desktop puts your MainSequence account inside Claude Desktop's Code sessions: one native pane with Home, Code, Projects, Agents and Tools, agent conversations from Claude's own composer, project MCP tools Claude can call, and the deployment status of your repositories. It is the plugin mainsequence in the mainsequence-desktop marketplace, distributed as a ZIP from this repository's releases.
It is built on the shared MainSequence developer tooling, which also powers the VS Code extension and the Codex plugin. One sign-in on your machine serves all three and the mainsequence and command-center-sdk command lines.
You need Claude Desktop with local Code sessions and plugin support (the plugin's startup guard requires the bundled Code engine 2.1.286 or newer), Node 22 or newer, and a MainSequence account. Repository setup needs Git and SSH; Python repositories need uv, Vite + React repositories need npm; local image builds need Docker with buildx. None of these are installed silently. The plugin runs on macOS and Linux. On Windows the shared sign-in cannot be kept between sessions, so the plugin is not supported there yet.
https://github.com/mainsequence-sdk/mainsequence-claude-desktop.git.mainsequence-desktop. The catalog points at a versioned ZIP with a pinned SHA-256; nothing is built or downloaded from npm on your machine./ms opens it too.The pane header always shows the plugin's version and whether a newer one is published. Update to … asks first, then runs Claude's own plugin updater; start a new Code session to use the new version. If the updater cannot run from the session, the pane lists the manual steps: refresh the marketplace, update the plugin and start a fresh Code session. /reload-plugins only reloads the installed copy. To install a specific build, download a release ZIP and use Add plugin → Upload plugin. If Claude cannot find Node, set its path in the plugin's Node executable setting or under Settings → Advanced → Runtime & diagnostics → Executable overrides.
https://api.main-sequence.app. If your organization runs its own MainSequence deployment, set its API URL as the backend in Settings before signing in. Set the Command Center URL there as well, so project and repository links open in Command Center, and the repositories folder where local checkouts go. Edits apply at once, with no Save step.MainSequence is one pane across your Code sessions: settings, the environment, the open section and Activity are the same in every session, whichever project it is in. Only the This session card on Home belongs to the session. See One MainSequence across sessions.
<folder>/<organization>/projects/<name>-<uid> with its deploy key and an endpoint-only .env, optionally installing dependencies. A local repository's detail offers status, dependencies, instructions and skills, folder, terminal, containers and a confirmed delete, plus a Deploys card with the latest run of the checked-out branch, its step timeline and logs, and the environment's deployment history./add-dir, without a model turn./ms @<agent-handle> . Write your message after the prefix and submit it yourself; the reply appears in the transcript with the agent's name, and the composer stays addressed to that agent until you press End or Switch agent in the band above it. Task-capable agents take /ms task @<handle> . See Agents.ms_<tool>_<hash> with the project's own schema, and Claude asks before each call. Advanced in an MCP's detail calls a tool directly or re-registers the tools. The read-only ms_deployment_status tool gives Claude the same deployment view as the Deploys card.The user guide describes every section, and agent icons how the platform's icons are shown.
/ms.Signed terminals, changing the Code session's root folder and launching container sessions are host limitations: the pane shows them disabled with the reason.
This repository owns the Claude UI, lifecycle hooks, worker transport, tests and releases. Shared behaviour comes from the exact @dev-mainsequence/devtools package pinned in package.json and the lockfile, bundled into the portable ZIP; no sibling checkout or runtime npm download is used. Host API functions stay in claude/hooks/register.mjs because Claude's effect analysis follows helpers only within the same module.
npm ci --ignore-scripts
npm run release:check
release:check type-checks, builds, runs the Node tests, verifies the documentation, packs the ZIP and verifies the distribution in an isolated directory. Set MS_CLAUDE_DESKTOP_BINARY for native loader validation and the simulator tests, and disposable CLAUDE_CONFIG_DIR and CLAUDE_SECURESTORAGE_CONFIG_DIR for native checks; npm run verify:claude runs them. Tests use synthetic MainSequence responses; live GUI acceptance is recorded in each release's pull request.
To release: set the new version in package.json and package-lock.json, run npm run release:check, merge, and push an annotated tag v<version> whose message is the release notes. The release workflow builds, repeats the checks, creates the GitHub release for that tag with the ZIP, its checksum and those notes, and commits the updated marketplace catalog. npm run publish:claude does the same from a clean, pushed checkout. Published assets are immutable; a wrong release needs a new version. Afterwards, npm run verify:published checks the catalog, the ZIP and the checksum anonymously and installs into a disposable configuration. See distribution, architecture and agent interactions.
hooks/register.mjs 1747 lines1import { VIEWS, PANE, initialState, meetsVersion, clean, record, rows, request, actionOptions, fail, publicValue, mcpDisplayName, setupKey, agentHandle, deployTarget,
2 basename, SHARED_SETTINGS, SEARCH_FIELDS, viewSnapshot, activityEntry, publishedOperation, remoteOperations, allOperations, ACTIVITY_KEEP_MS } from './model.mjs';
3import { renderPane, renderRecovery } from './view.mjs';
4import { EMBLEM } from './branding.mjs';
5import { workspaceBinding } from './workspace.mjs';
6import { renderAgentIcon, renderAgentActivity, agentIconActivity } from './agentIcon.mjs';
7import { HIDDEN_DEPLOYMENT, isActiveRun, defaultStep, failedStep, logSource, describeRun, logTimestamp, stateLabel, kindLabel, stepLabel } from './deploymentView.mjs';
8
9const sessionActions = new Set(['login', 'logout', 'refresh-token', 'auth-status', 'repositories']);
10const update = ($) => $.ui.invalidate('ui.render');
11
12async function openPane($, state) {
13 await $.ui.open({ id: PANE, title: 'MainSequence', focus: true, closeOnEscape: true });
14 state.paneOpen = true; syncDeploymentPolling($, state); syncAgentIconPolling($, state);
15 update($);
16 if (!state.booted) await ensureBoot($, state, { surface: 'desktop' });
17 syncSharedPolling($, state); void syncShared($, state, true);
18 if (state.pluginCheckedAt && await $.clock.now() - state.pluginCheckedAt > PLUGIN_RECHECK_MS) void checkPlugin($, state).catch(() => {});
19}
20
21// The plugin's own release: checked when the session starts and again on a pane open hours later.
22const PLUGIN_RECHECK_MS = 6 * 3_600_000;
23function checkPlugin($, state) {
24 if (state.pluginCheck) return state.pluginCheck;
25 state.pluginCheck = (async () => {
26 const result = await execute($, state, request('projects', 'plugin-version'), true);
27 if (result.status === 'success') { state.plugin = record(result.data); state.pluginCheckedAt = await $.clock.now(); }
28 else if (state.plugin.status !== 'restart_required') state.plugin = { ...state.plugin, status: 'unknown' };
29 })().finally(() => { state.pluginCheck = null; update($); });
30 update($);
31 return state.pluginCheck;
32}
33async function updatePlugin($, state) {
34 if (state.pluginUpdating) return;
35 state.pluginUpdating = true; state.pluginHelp = ''; update($);
36 try {
37 const result = await execute($, state, request('projects', 'plugin-update'));
38 if (result.status === 'success') { state.plugin = { ...state.plugin, ...record(result.data) }; $.ui.toast(result.summary); }
39 else if (result.status === 'failure') state.pluginHelp = result.summary;
40 } finally { state.pluginUpdating = false; update($); }
41}
42
43function ensureBoot($, state, e) {
44 if (state.booting) return state.booting;
45 if (state.booted && !state.closed) return Promise.resolve();
46 state.booting = boot($, state, e).then(() => { state.booted = true; }, () => {
47 state.paneFault = { phase: 'startup', element: '' };
48 }).finally(() => { state.booting = null; update($); });
49 return state.booting;
50}
51
52function operationTitle(action) {
53 return ({ 'auth-status': 'Check MainSequence connection', login: 'Sign in to MainSequence', logout: 'Sign out of MainSequence',
54 'refresh-token': 'Renew MainSequence session', repositories: 'Refresh code repositories', setup: 'Set up repository',
55 status: 'Check repository status', sync: 'Sync dependencies', install: 'Install dependencies', 'sdk-status': 'Check SDK',
56 'mcp-discover': 'Find MCPs', 'mcp-list': 'Refresh MCP connections', 'mcp-add': 'Connect MCP',
57 'mcp-remove': 'Disconnect MCP', 'mcp-tools': 'Read MCP tools', 'mcp-call': 'Call MCP tool', 'mcp-catalog': 'Register MCP tools', 'mcp-tool-call': 'Call MCP tool',
58 'agents-list': 'Find agents', 'agent-get': 'Read agent details', 'agent-session': 'Open agent conversation', 'agent-readiness': 'Prepare agent', 'agent-send': 'Send agent message',
59 'agent-task-get': 'Check agent task', 'agent-task-wait': 'Wait for agent task', 'agent-task-cancel': 'Cancel agent task',
60 'organization-projects': 'Load MainSequence projects', 'organization-project': 'Load project branches', 'organization-project-link': 'Open project resource',
61 'organization-project-setup': 'Set up project locally', 'open-project-workspace': 'Open project in Claude',
62 'local-repositories': 'Discover local repositories', 'deployment-history': 'Read deployment history', 'deployment-run': 'Read deployment run',
63 'deployment-logs': 'Read deployment logs', 'plugin-version': 'Check for MainSequence updates', 'plugin-update': 'Update MainSequence plugin' })[action] || action.replaceAll('-', ' ');
64}
65
66async function execute($, state, domain, quiet = false, observer = {}) {
67 const action = domain.request.action;
68 const contextRevision = state.contextRevision, backend = state.fields.backend;
69 if (!state.ready || state.closed) return fail(action, state.problem || 'This session is not ready.', 'UNSUPPORTED_ACTION');
70 const setupTarget = action === 'setup' ? setupKey(domain.request) : null;
71 if (setupTarget && [...state.running.values(), ...state.remoteOperations.filter(op => op.status === 'running')].some(op => op.setupTarget === setupTarget)) {
72 $.ui.toast('This repository is already being set up. Its progress is shown in Code.');
73 return fail(action, 'This repository is already being set up.', 'PROJECT_BUSY');
74 }
75 if (state.running.size >= 8) return fail(action, 'Wait for a running operation to finish.', 'PROJECT_BUSY');
76 const id = `${state.sessionId}-${++state.sequence}`;
77 const op = { id, title: operationTitle(action), context: domain.request.environmentUid ? `Environment: ${environmentLabel(state)}` : domain.scope === 'devtools' && !sessionActions.has(action) ? domain.request.root || 'MainSequence' : 'MainSequence', label: `${domain.scope} · ${action}`, status: 'running', progress: 'Starting…', cancelled: false };
78 if (setupTarget) {
79 op.setupTarget = setupTarget;
80 op.context = `${rows(state.results['devtools.repositories']?.data).find(row => String(row.uid) === String(domain.request.repositoryUid))?.code_repository_name || domain.request.repositoryUid} · ${domain.request.branch}`;
81 op.destination = domain.request.baseDir;
82 op.progress = 'Checking repository access and preparing the local folder…';
83 }
84 state.running.set(id, op); observer.started?.(op);
85 if (!quiet) {
86 state.operations.push(op); state.operations = state.operations.filter((value, index, all) => value.status === 'running' || index >= all.length - 30); statusLine($, state); update($);
87 // Activity is ordered across sessions by start time.
88 void $.clock.now().then(now => { op.startedAt = now; shareActivity($, state); }, () => {});
89 }
90 const envelope = { protocolVersion: 1, id, request: domain };
91 let result = await runWorker($, state, envelope, op, message => { op.progress = message; observer.progress?.(message); if (!quiet) { update($); shareActivity($, state); } });
92 rememberMcpNames(state, result);
93 const publicResult = publicValue(result);
94 // Catalog paging needs every tool; the generic activity preview caps arrays.
95 if (action === 'mcp-tools' && Array.isArray(result.data?.tools)) publicResult.data.tools = result.data.tools.map(tool => publicValue(tool));
96 op.status = publicResult.status; op.progress = publicResult.summary; op.result = publicResult;
97 state.running.delete(id);
98 if (!quiet) { statusLine($, state); shareActivity($, state); }
99 const currentMcp = !['mcp-tools', 'mcp-call'].includes(action) || domain.request.releaseUid === state.selectedMcp;
100 // Session results depend on the backend alone: an environment or folder change while
101 // the sign-in check is in flight must not discard it.
102 const current = domain.scope === 'devtools' && sessionActions.has(action) ? backend === state.fields.backend : contextRevision === state.contextRevision;
103 if (current && currentMcp && !state.closed && !action.startsWith('organization-project') && !['agents-list', 'agent-get', 'agent-icons', 'mcp-catalog', 'mcp-tool-call', 'plugin-version', 'plugin-update', ...DEPLOY_ACTIONS].includes(action)) state.results[`${domain.scope}.${action}`] = publicResult;
104 if (!quiet) update($);
105 return result;
106}
107
108/** A connection's name comes from an optional backend lookup. Keep the last name each listing
109 * resolved so a lookup that fails during a backend restart does not relabel a working MCP. */
110function rememberMcpNames(state, result) {
111 if (result?.status !== 'success' || !['mcp-list', 'mcp-discover', 'mcp-add'].includes(result.action)) return;
112 for (const row of result.action === 'mcp-add' ? [record(result.data).connection] : rows(result.data)) {
113 if (!row || typeof row !== 'object' || typeof row.resource_release_uid !== 'string') continue;
114 const key = `${row.backend}\n${row.resource_release_uid}`;
115 if (typeof row.display_name === 'string' && row.display_name.trim()) state.mcpNames[key] = row.display_name;
116 else if (state.mcpNames[key]) row.display_name = state.mcpNames[key];
117 }
118}
119
120/** Mirror the running operations of every session in Claude's status line. */
121function statusLine($, state) {
122 const running = allOperations(state).filter(op => op.status === 'running').length;
123 $.ui.status(running ? `MainSequence: ${running} running` : undefined);
124}
125
126function devRequest(state, action, extra = {}) {
127 const f = state.fields;
128 const common = actionOptions(state);
129 if (sessionActions.has(action)) { delete common.root; delete common.projectType; }
130 if (action === 'setup') return request('devtools', action, { ...(f.backend.trim() ? { backend: f.backend.trim() } : {}), repositoryUid: f.repositoryUid,
131 branch: f.branch, baseDir: f.baseDir, ...extra });
132 if (action.startsWith('mcp-')) {
133 const values = { ...(f.backend.trim() ? { backend: f.backend.trim() } : {}) };
134 if (action !== 'mcp-environments') {
135 if (!f.mcpEnvironment) throw new Error('Choose an organization environment in MCP first.');
136 values.environmentUid = f.mcpEnvironment;
137 }
138 if (['mcp-add', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(action)) values.releaseUid = state.selectedMcp;
139 // Discovery rows are labelled with their repository's name by devtools; saved connections carry their names.
140 if (action === 'mcp-discover') Object.assign(values, { search: f.mcpSearch, limit: 25, offset: 0, enrichNames: true });
141 if (action === 'mcp-call') {
142 const args = JSON.parse(f.mcpArguments);
143 if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Arguments must be a JSON object.');
144 Object.assign(values, { tool: f.mcpTool, arguments: args });
145 }
146 return request('devtools', action, { ...values, ...extra });
147 }
148 return request('devtools', action, { ...common, ...extra });
149}
150
151async function runDev($, state, action, extra = {}) {
152 const revision = state.contextRevision;
153 let domain;
154 if (state.preferencesLoading) { $.ui.toast('Workspace settings are loading.'); return; }
155 try { domain = devRequest(state, action, extra); }
156 catch { $.ui.toast(action.startsWith('mcp-') && !state.fields.mcpEnvironment ? 'Choose an organization environment in MCP first.' : 'Enter valid JSON object arguments before calling the tool.'); return; }
157 if (action === 'mcp-discover') state.mcpOffset = domain.request.offset;
158 const result = await execute($, state, domain);
159 if (state.closed || revision !== state.contextRevision) return result;
160 // The other sessions re-read what this changed: the machine login, the environment's connections, the local checkouts.
161 const changed = result.status === 'success' && !extra.dryRun && (['login', 'logout'].includes(action) ? 'auth' : ['mcp-add', 'mcp-remove'].includes(action) ? 'mcp' : action === 'setup' ? 'repositories' : '');
162 if (changed) void signal($, state, changed).catch(() => {});
163 if (result.status === 'success' && ['mcp-add', 'mcp-remove'].includes(action)) await refreshTools($, state);
164 if (result.status === 'success' && ['login', 'logout', 'refresh-token'].includes(action)) await execute($, state, devRequest(state, 'auth-status', { check: true }), true);
165 if (result.status === 'success' && !extra.dryRun && ['sync', 'install', 'update-sdk', 'update-agents', 'install-skills', 'skills-sync', 'env-cleanup'].includes(action)) {
166 await execute($, state, devRequest(state, 'status'), true);
167 if (action === 'update-sdk' || action === 'install') await execute($, state, devRequest(state, 'sdk-status'), true);
168 }
169 if (result.status === 'success' && action === 'logout') clearSignedOut($, state);
170 if (result.status === 'success' && action === 'mcp-remove') state.toolsView = 'list';
171 if (result.status === 'success' && action === 'setup') {
172 await runProject($, state, 'local-repositories');
173 if (state.closed || revision !== state.contextRevision) return result;
174 const local = rows(state.results['projects.local-repositories']?.data).find(row => String(row.repositoryUid) === String(state.fields.repositoryUid));
175 if (local && state.codeDetail?.repositoryUid === state.fields.repositoryUid && !state.codeDetail.root) await openCode($, state, { root: local.path, repositoryUid: state.fields.repositoryUid });
176 }
177 update($);
178 return result;
179}
180
181/** What a sign-out leaves: no environment, MCP connections or repositories of the account. */
182function clearSignedOut($, state) {
183 clearMcp($, state); state.fields.mcpEnvironment = ''; delete state.results['devtools.mcp-environments']; state.selectedLocal = ''; state.fields.repositoryUid = ''; state.fields.branch = '';
184 for (const key of ['devtools.repositories', 'projects.local-repositories', 'devtools.mcp-list', 'devtools.mcp-discover', 'devtools.mcp-tools']) delete state.results[key];
185}
186
187async function runProject($, state, action, extra = {}) {
188 const revision = state.contextRevision;
189 const f = state.fields;
190 const values = { root: action.startsWith('delete-local') ? state.selectedLocal : f.root || state.cwd,
191 baseDir: f.baseDir, organization: f.organization, ...(f.image ? { image: f.image } : {}), ...(f.platform ? { platform: f.platform } : {}), ...extra };
192 const destination = values.destination; delete values.destination;
193 if (action === 'pick-folder') values.initialPath = f[destination] || state.cwd;
194 if (action === 'local-repositories') values.repositories = rows(state.results['devtools.repositories']?.data).map(row => ({ uid: row.uid, name: row.code_repository_name || row.name }));
195 const result = await execute($, state, request('projects', action, values));
196 if (state.closed || result.status !== 'success' || revision !== state.contextRevision) return result;
197 const data = record(result.data);
198 if (action === 'workspace-profile' && typeof data.organization === 'string') state.fields.organization = data.organization;
199 if (action === 'pick-folder' && typeof data.path === 'string' && ['root', 'baseDir'].includes(destination)) {
200 state.editedSettings.add(destination); applyField($, state, destination, data.path);
201 if (destination === 'baseDir') shareSettings($, state);
202 }
203 if (action === 'delete-local') void signal($, state, 'repositories').catch(() => {});
204 if (action === 'delete-local-preview') state.confirmation = { title: 'Permanently delete local repository', detail: `${data.root}\n${data.warning || ''}`,
205 callback: () => runProject($, state, 'delete-local', { ...values, confirmation: data.confirmation }) };
206 if (action === 'docker-preview') state.confirmation = { title: 'Build Docker image', detail: `${data.root}\n${(data.args || []).join(' ')}`,
207 callback: () => runProject($, state, 'docker-build', values) };
208 if (action === 'devcontainer-preview') state.confirmation = { title: 'Write container configuration', detail: `${data.path}\nBefore:\n${data.before || '(absent)'}\nAfter:\n${data.after}`,
209 callback: () => runProject($, state, 'devcontainer-write', { ...values, expectedDigest: data.expectedDigest }) };
210 if (action === 'delete-local') { state.selectedLocal = ''; await runProject($, state, 'local-repositories'); }
211 update($);
212 return result;
213}
214
215const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
216/** MCP blocks use MCP's shape; Claude's core expects Messages API blocks: text and base64 images pass, the rest becomes text. */
217function apiBlock(block) {
218 if (block.type === 'text') return { type: 'text', text: clean(String(block.text ?? '')) };
219 if (block.type === 'image' && typeof block.data === 'string' && IMAGE_TYPES.has(block.mimeType)) return { type: 'image', source: { type: 'base64', media_type: block.mimeType, data: block.data } };
220 if (block.type === 'image' && block.source && typeof block.source === 'object') return { type: 'image', source: block.source };
221 const resource = block.resource && typeof block.resource === 'object' ? block.resource : null;
222 const { data, ...rest } = resource ? { ...block, resource: { ...resource, blob: undefined } } : block;
223 const binary = data !== undefined || resource?.blob !== undefined;
224 return { type: 'text', text: clean(JSON.stringify({ ...rest, ...(binary ? { omitted: 'binary content' } : {}) })) };
225}
226
227/** Claude accepts a registered tool's result as text, content blocks or nothing: never a bare object.
228 * A project tool's result arrives from the worker already shaped by devtools (`mcp-tool-call`: the remote
229 * blocks, then a JSON block with summary, effects, error and structuredContent; devtools ADR 0004); this
230 * only converts the blocks. A host tool's own blocks pass the same way, and a bare ActionResult becomes
231 * one JSON text block. */
232export function toolResult(value) {
233 const result = publicValue(value);
234 const data = record(result?.data);
235 const blocks = Array.isArray(data.content) ? data.content.filter(block => block && typeof block === 'object' && typeof block.type === 'string') : [];
236 const content = blocks.length ? blocks
237 : [{ type: 'text', text: JSON.stringify(Array.isArray(data.content) ? { summary: result?.summary, ...(result?.error ? { error: result.error } : {}) } : result) }];
238 return { result: content.map(apiBlock), ...(result?.status !== 'success' || data.isError === true ? { isError: true } : {}) };
239}
240
241function refreshTools($, state) {
242 // A tool call that arrives while tools are being registered waits for this run.
243 const run = refreshToolsNow($, state).finally(() => { if (state.toolRefresh === run) state.toolRefresh = null; });
244 state.toolRefresh = run;
245 return run;
246}
247
248/** Startup work (preferences, session check, environments and MCP tools) runs once per session, from whichever trigger comes first. */
249function ensureInitialized($, state) {
250 if (state.initPromise) return state.initPromise;
251 state.preferencesLoading = true; update($);
252 state.initPromise = initializeWorkspace($, state).catch(() => { state.initializing = false; state.preferencesLoading = false; update($); });
253 return state.initPromise;
254}
255
256/** A desktop render or a tool call proves the session is live even when session.start named no surface (SDK-hosted Desktop, reloads).
257 * Desktop draws a session again when the person switches to it: the draw reads what the other sessions changed. */
258function wakeOnDesktop($, state, e) {
259 if (e.surface !== 'desktop' || state.paneFault) return;
260 if (!state.booted && !state.booting) { void ensureBoot($, state, e); return; }
261 if (state.booted && !state.initPromise) void ensureInitialized($, state);
262 else void syncShared($, state);
263}
264
265async function toolsReady($, state) {
266 let timer;
267 const limit = new Promise(resolve => { timer = $.clock.after(90000, resolve); });
268 const work = (async () => {
269 await ensureBoot($, state, { surface: 'desktop' });
270 await ensureInitialized($, state);
271 while (state.toolRefresh) await state.toolRefresh;
272 })();
273 try { await Promise.race([work, limit]); } finally { timer?.cancel(); }
274}
275
276async function refreshToolsNow($, state) {
277 const revision = state.contextRevision;
278 // Tools registered by the previous run stay registered with Claude. Names, definitions and
279 // bindings come from devtools' shared project-tool registry (ADR 0004): its catalog file keeps
280 // a connection's earlier tools while its catalog cannot be read (a backend restart, a network
281 // blip) and names it; a removed connection loses them there. Removed names are refused below.
282 const previous = state.mcpTools;
283 state.mcpTools = {};
284 if (!state.fields.mcpEnvironment || state.preferencesLoading) return;
285 const environmentUid = state.fields.mcpEnvironment;
286 const kept = () => Object.fromEntries(Object.entries(previous).filter(([, entry]) => entry.environmentUid === environmentUid));
287 // The pane lists the environment's connections from this listing.
288 const listed = await execute($, state, devRequest(state, 'mcp-list'), true);
289 if (state.closed || revision !== state.contextRevision) return;
290 if (listed.status !== 'success') { state.mcpTools = kept(); state.results['mcp.registration'] = publicValue(listed); update($); }
291 else {
292 // The first registration of an environment comes from the shared catalog file at once, before
293 // any connection is read; the forced read that follows brings it up to date. Later refreshes
294 // (connect, disconnect, Re-register) read the connections directly.
295 const cold = !Object.keys(kept()).length;
296 const registered = new Map();
297 let any = false;
298 for (const step of cold ? [{}, { refresh: 'force' }] : [{ refresh: 'force' }]) {
299 const answer = await execute($, state, devRequest(state, 'mcp-catalog', step), true);
300 if (state.closed || revision !== state.contextRevision) return;
301 const data = record(answer.data);
302 const definitions = Array.isArray(data.definitions) ? data.definitions : answer.status === 'success' ? [] : null;
303 if (!definitions) { if (!any) state.mcpTools = kept(); state.results['mcp.registration'] = publicValue(answer); update($); continue; }
304 const bindings = record(data.bindings), next = {};
305 for (const definition of definitions) {
306 const binding = record(bindings[definition.name]);
307 if (typeof definition.name !== 'string' || !definition.name || typeof binding.tool !== 'string') continue;
308 const full = `mcp__${$.plugin.name}__${definition.name}`;
309 next[full] = { backend: binding.backend, environmentUid: binding.environmentUid, releaseUid: binding.releaseUid, tool: binding.tool };
310 const spec = { name: definition.name, description: clean(definition.description || definition.name), inputSchema: record(definition.inputSchema) };
311 const identity = JSON.stringify(spec);
312 if (registered.get(spec.name) === identity) continue;
313 registered.set(spec.name, identity);
314 await $.tool.register(spec);
315 if (state.closed || revision !== state.contextRevision) return;
316 }
317 state.mcpTools = next; any = true;
318 const count = Object.keys(next).length, unavailable = rows(data.unavailable).map(clean), retained = Number(data.retained) || 0;
319 state.results['mcp.registration'] = answer.status !== 'success' ? publicValue(answer) : { schemaVersion: 1, action: 'mcp-registration', status: 'success',
320 summary: !unavailable.length ? `${count} MainSequence tools available from the next prompt.`
321 : `${count} MainSequence tools available from the next prompt. ${unavailable.join(', ')} could not be read now${retained ? '; its earlier tools stay registered' : ''}. Refresh to try again.`,
322 data: { unavailable, retained } };
323 update($);
324 }
325 }
326 // The deployment status tool is registered once per environment selection. It reads only, and the
327 // handler denies it while no environment is selected, so a failed MCP listing must not withhold it.
328 await $.tool.register({ name: DEPLOY_TOOL, description: clean(`MainSequence deployments [${environmentLabel(state)}]: read the latest deployment or workflow run of a repository branch (default: this session's checkout), or one run by uid, with its steps, states and timings. logs: true adds the last 80 log lines of the first failed step. Read-only.`),
329 inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'MainSequence repository name or uid; defaults to the session folder\'s repository' },
330 branch: { type: 'string', description: 'Branch name; defaults to the checked-out branch' }, runUid: { type: 'string', description: 'A deployment or workflow run uid, instead of repository and branch' },
331 logs: { type: 'boolean', description: 'Include the last 80 log lines of the first failed step' } }, additionalProperties: false } });
332}
333
334function environmentLabel(state) {
335 return rows(state.results['devtools.mcp-environments']?.data).find(row => row.uid === state.fields.mcpEnvironment)?.name || state.fields.mcpEnvironment;
336}
337function clearMcp($, state) {
338 clearAgents($, state); clearOrganizationProjects($, state); clearDeployments($, state);
339 state.contextRevision++; state.mcpTools = {}; state.selectedMcp = ''; state.confirmation = null; state.toolsView = 'list';
340 state.fields.mcpTool = ''; state.mcpOffset = 0;
341 for (const key of ['mcp-tool', 'mcp-environment']) delete state.selectPages[key];
342 for (const key of ['devtools.mcp-discover', 'devtools.mcp-list', 'devtools.mcp-tools', 'devtools.mcp-call', 'mcp.registration']) delete state.results[key];
343 for (const op of state.running.values()) if (op.label.startsWith('devtools · mcp-') && op.cancel) void op.cancel().catch(() => {});
344 update($);
345}
346async function selectEnvironment($, state, uid, persist = true) {
347 if (uid && !rows(state.results['devtools.mcp-environments']?.data).some(row => row.uid === uid)) return;
348 clearMcp($, state); state.fields.mcpEnvironment = uid;
349 const revision = state.contextRevision, backend = state.fields.backend;
350 state.environmentSelections[backend] = uid;
351 if (persist) {
352 const previous = record(await $.store.get('mcp-environments'));
353 if (revision === state.contextRevision && !state.closed) {
354 const selections = { ...previous, [backend]: uid };
355 await $.store.set('mcp-environments', selections);
356 state.environmentSelections = selections; state.shared.environments = JSON.stringify(selections);
357 }
358 }
359 if (!uid || revision !== state.contextRevision || state.closed) return;
360 // A slow connected server must not hold the directory behind tool registration.
361 await Promise.all([refreshTools($, state), runDev($, state, 'mcp-discover')]);
362 if (revision === state.contextRevision && state.tab === 'Agents') await loadAgents($, state);
363 if (revision === state.contextRevision && state.tab === 'Projects') await loadOrganizationProjects($, state);
364 if (revision === state.contextRevision) await ensureLatestDeployments($, state);
365}
366async function loadEnvironments($, state) {
367 if (state.environmentsLoading || state.preferencesLoading) return;
368 state.environmentsLoading = true; const revision = state.contextRevision; update($);
369 try {
370 const result = await execute($, state, request('devtools', 'mcp-environments', { backend: state.fields.backend }), true);
371 if (revision !== state.contextRevision || state.closed) return;
372 if (result.status !== 'success') { clearMcp($, state); state.fields.mcpEnvironment = ''; return; }
373 // The shared selection wins: another session may have chosen a different environment since this one did.
374 const selected = state.environmentSelections[state.fields.backend] || state.fields.mcpEnvironment;
375 const uid = rows(result.data).some(row => row.uid === selected) ? selected : '';
376 state.environmentsLoading = false; update($);
377 await selectEnvironment($, state, uid, false);
378 } finally { state.environmentsLoading = false; update($); }
379}
380
381function selectTab($, state, tab) {
382 if (!VIEWS.includes(tab)) return;
383 state.tab = tab; state.confirmation = null;
384 // Remote-run polling and the agent icon re-check follow the section: timers for what is no longer shown are cancelled here.
385 syncDeploymentPolling($, state); syncAgentIconPolling($, state); update($);
386 loadTab($, state);
387}
388/** What the open section draws and has not loaded yet. */
389function loadTab($, state) {
390 const tab = state.tab;
391 const background = work => { void Promise.resolve(work).catch(() => $.ui.toast('The action could not complete. Check Activity and diagnostics.')); };
392 // While startup is still resolving the session, initializeWorkspace loads the
393 // environments for whichever section is open; a second load here would clear
394 // and refetch agents, tools and icons.
395 if (['Projects', 'Tools', 'Agents'].includes(tab) && !state.results['devtools.mcp-environments']) { if (!state.initializing) background(loadEnvironments($, state)); }
396 else if (tab === 'Agents' && !state.results['devtools.agents-list']) background(loadAgents($, state));
397 if (tab === 'Projects' && state.fields.mcpEnvironment && !state.projectPage) background(loadOrganizationProjects($, state));
398 if (tab === 'Code' && !state.results['devtools.repositories'] && !state.repositoriesLoading) background(loadRepositories($, state));
399 if (tab === 'Home' || tab === 'Code') background(ensureLatestDeployments($, state));
400}
401
402/** A field edit: the shared settings and the session's own operation target clear what depended on them. */
403function applyField($, state, key, value) {
404 if (!Object.hasOwn(state.fields, key)) return;
405 state.fields[key] = String(value);
406 if (key === 'repositoryUid') state.fields.branch = '';
407 if (['branch', 'baseDir'].includes(key)) state.confirmation = null;
408 if (key === 'backend') {
409 state.selectPages = {};
410 clearMcp($, state); state.fields.mcpEnvironment = '';
411 state.contextRevision++; state.codeDetail = null;
412 state.fields.repositoryUid = ''; state.fields.branch = ''; state.fields.organization = ''; state.selectedLocal = '';
413 for (const resultKey of Object.keys(state.results)) delete state.results[resultKey];
414 state.selectedMcp = ''; state.mcpTools = {}; state.confirmation = null;
415 }
416 if (key === 'baseDir') { clearOrganizationProjects($, state); state.contextRevision++; state.selectedLocal = ''; delete state.results['projects.local-repositories']; }
417 if (key === 'root' || key === 'projectType') { state.contextRevision++; state.confirmation = null; }
418 if (key === 'root' || key === 'projectType' || key === 'backend') { delete state.results['devtools.status']; delete state.results['devtools.sdk-status']; }
419}
420
421function actionsFor($, state) {
422 // Every action is the person's: what it changes on screen is published for the other sessions, also once its work settles.
423 const background = work => { void Promise.resolve(work).catch(() => $.ui.toast('The action could not complete. Check Activity and diagnostics.')).finally(() => shareView($, state)); };
424 const actions = {
425 open: () => background(openPane($, state)),
426 retryPane: () => { state.paneFault = null; update($); if (!state.booted) background(ensureBoot($, state, { surface: 'desktop' })); },
427 selectPage: (key, page) => { state.selectPages[key] = page; update($); },
428 setup: install => {
429 if ([...state.running.values()].some(op => op.setupTarget === setupKey(state.fields))) return;
430 let input;
431 try { input = devRequest(state, 'setup', { install: !!install }).request; } catch { return; }
432 state.confirmation = { title: install ? 'Set up and install repository' : 'Set up repository',
433 detail: `Set up branch ${input.branch} in ${input.baseDir}${install ? ' and install its dependencies' : ''}.`,
434 callback: () => runDev($, state, 'setup', input) };
435 update($);
436 },
437 tab: tab => selectTab($, state, tab),
438 openCode: target => background(openCode($, state, target)),
439 closeCode: () => { state.codeDetail = null; state.confirmation = null; syncDeploymentPolling($, state); update($); },
440 openDeployment: (uid, kind, origin) => background(openDeployment($, state, uid, kind, origin)),
441 openDeployments: origin => background(openDeploymentList($, state, origin)),
442 closeDeployments: () => closeDeployments($, state),
443 deployBack: () => background(deployBack($, state)),
444 selectDeployStep: id => background(selectDeployStep($, state, id)),
445 deployLogsNewer: () => { const logs = state.deployLogs; if (logs?.nextCursor) background(loadDeploymentLogs($, state, { cursor: logs.nextCursor, previous: [...logs.previous, logs.cursor] })); },
446 deployLogsOlder: () => { const logs = state.deployLogs; if (logs?.previous.length) background(loadDeploymentLogs($, state, { cursor: logs.previous.at(-1), previous: logs.previous.slice(0, -1) })); },
447 refreshDeployments: () => background(refreshDeployments($, state)),
448 moreDeployments: () => background(loadDeploymentList($, state, { offset: state.deployList?.entries.length || 0, limit: 25 })),
449 codeFilter: value => { state.codeFilter = value === 'local' ? 'local' : 'all'; update($); },
450 checkHomeStatus: () => background(openCode($, state, { root: state.cwd }, false)),
451 showProjectList: () => { state.projectListView = true; update($); },
452 openAddMcp: () => { state.toolsView = 'add'; state.confirmation = null; update($); if (!state.results['devtools.mcp-discover']) background(runDev($, state, 'mcp-discover', { offset: 0 })); },
453 closeTool: () => { state.toolsView = 'list'; state.confirmation = null; update($); },
454 openTool: uid => background(openTool($, state, uid)),
455 connectMcp: (uid, name) => { state.selectedMcp = uid; state.confirmation = { title: 'Connect MCP', detail: `Connect ${name} in ${environmentLabel(state)} and register its tools for Claude.`, callback: () => runDev($, state, 'mcp-add') }; update($); },
456 disconnectMcp: (uid, name) => { state.selectedMcp = uid; state.confirmation = { title: 'Disconnect MCP', detail: `Disconnect ${name} from ${environmentLabel(state)}. Its tools stop being available to Claude.`, callback: () => runDev($, state, 'mcp-remove') }; update($); },
457 askAgent: (uid, message) => background(talkToAgent($, state, uid, false, message)),
458 endAgentConversation: () => background(endAgentConversation($, state)),
459 allowAgentResend: uid => background((async () => {
460 const binding = Object.values(state.agentBindings).find(value => value.agent.uid === uid && current(state, value));
461 if (!binding || binding.busy || !binding.uncertain) return;
462 // The person reviewed the uncertain delivery; only they may clear the marker.
463 binding.uncertain = false; binding.status = binding.sessionUid ? 'selected' : binding.status; binding.error = '';
464 await saveBinding($, state, binding); refresh($);
465 })()),
466 refreshEnvironments: () => background(loadEnvironments($, state)),
467 refreshAgents: () => { state.agentIconTimes = {}; state.agentIconPolls = 0; background(loadAgents($, state, 0, true)); },
468 searchAgents: () => background(loadAgents($, state)),
469 nextAgents: () => background(loadAgents($, state, state.agentOffset + 25)),
470 previousAgents: () => background(loadAgents($, state, Math.max(0, state.agentOffset - 25))),
471 agentDetails: uid => background(getAgent($, state, uid).then(result => { if (result) { state.agentDetail = result.agent; state.tab = 'Agents'; update($); } })),
472 closeAgentDetails: () => { state.agentDetail = null; update($); },
473 talkToAgent: uid => background(talkToAgent($, state, uid)),
474 cancelAgentPreparation: uid => {
475 const preparation = state.agentPreparing[uid];
476 if (preparation) { preparation.cancelled = true; if (preparation.operation?.cancel) background(preparation.operation.cancel()); }
477 refresh($);
478 },
479 newAgentConversation: uid => { state.confirmation = { title: 'Start a new agent conversation', detail: 'The agent starts a separate conversation. An earlier request may still be running; this does not cancel it.', callback: () => talkToAgent($, state, uid, true) }; update($); },
480 selectEnvironment: uid => background(selectEnvironment($, state, uid)),
481 toggle: key => { state.expanded[key] = !state.expanded[key]; update($); },
482 refreshOrganizationProjects: () => background(loadOrganizationProjects($, state)),
483 nextProjects: () => background(loadOrganizationProjects($, state, (state.projectPage?.offset || 0) + 25)),
484 previousProjects: () => background(loadOrganizationProjects($, state, Math.max(0, (state.projectPage?.offset || 0) - 25))),
485 chooseProject: uid => { state.projectListView = false; background(chooseOrganizationProject($, state, uid)); },
486 projectLink: (target, branchUid) => background(openProjectLink($, state, target, branchUid)),
487 setupOrganizationProject: () => confirmOrganizationSetup($, state),
488 openOrganizationWorkspace: () => background(openOrganizationWorkspace($, state)),
489 refreshRepositories: () => background(loadRepositories($, state)),
490 field: (key, value) => {
491 if (Object.hasOwn(state.fields, key) && state.fields[key] !== String(value)) {
492 state.editedSettings.add(key); applyField($, state, key, value);
493 if (SHARED_SETTINGS.includes(key)) shareSettings($, state);
494 }
495 update($);
496 },
497 dev: (action, extra) => background(runDev($, state, action, extra)),
498 project: (action, extra) => background(runProject($, state, action, extra)),
499 refreshTools: () => background(refreshTools($, state)),
500 confirm: (title, detail, callback) => { state.confirmation = { title, detail, callback }; update($); },
501 dismiss: () => { state.confirmation = null; update($); },
502 accept: () => { const confirmation = state.confirmation; state.confirmation = null; update($); if (confirmation) background(confirmation.callback()); },
503 cancel: id => { const op = state.operations.find(value => value.id === id); if (op?.cancel) { op.progress = 'Cancelling…'; background(op.cancel()); update($); } },
504 checkPlugin: () => background(checkPlugin($, state)),
505 updatePlugin: () => {
506 const plugin = state.plugin;
507 if (!plugin.updateSupported) { state.pluginHelp = "This session can't run Claude's plugin updater, or MainSequence was not installed from the mainsequence-desktop marketplace."; update($); return; }
508 state.confirmation = { title: 'Update MainSequence', detail: `Claude refreshes the mainsequence-desktop marketplace and installs MainSequence ${plugin.latest}. This session keeps ${plugin.running} until you start a new Code session.`,
509 callback: () => updatePlugin($, state) };
510 update($);
511 },
512 closePluginHelp: () => { state.pluginHelp = ''; update($); },
513 attachWorkspace: () => background(attachWorkspace($, state)),
514 };
515 return Object.fromEntries(Object.entries(actions).map(([name, action]) => [name, (...args) => { const answer = action(...args); shareView($, state); return answer; }]));
516}
517
518async function openTool($, state, uid) {
519 state.selectedMcp = uid; state.toolsView = 'detail'; state.confirmation = null; state.fields.mcpTool = '';
520 delete state.selectPages['mcp-tool']; delete state.results['devtools.mcp-tools']; delete state.results['devtools.mcp-call']; update($);
521 await runDev($, state, 'mcp-tools');
522}
523
524/** Open a repository detail. A local folder becomes the operation target; a remote one offers setup. */
525async function openCode($, state, target, show = true) {
526 const repositoryUid = target.repositoryUid ? String(target.repositoryUid) : '';
527 if (show) { state.tab = 'Code'; state.confirmation = null; }
528 if (!target.root) {
529 state.fields.repositoryUid = repositoryUid;
530 const branches = rows(rows(state.results['devtools.repositories']?.data).find(row => String(row.uid) === repositoryUid)?.branches);
531 state.fields.branch = branches.length === 1 ? branches[0].repository_branch : '';
532 state.codeDetail = { root: '', repositoryUid }; update($); return;
533 }
534 const changed = state.fields.root !== target.root;
535 if (changed) { state.contextRevision++; delete state.results['devtools.status']; delete state.results['devtools.sdk-status']; }
536 state.fields.root = target.root; state.selectedLocal = target.root; state.fields.repositoryUid = repositoryUid;
537 if (show) state.codeDetail = { root: target.root, repositoryUid };
538 update($);
539 if (changed || !state.results['devtools.status']) {
540 const revision = state.contextRevision;
541 await runDev($, state, 'status');
542 if (!state.closed && revision === state.contextRevision) await ensureLatestDeployments($, state);
543 if (!state.closed && revision === state.contextRevision && !state.results['devtools.sdk-status']) await runDev($, state, 'sdk-status');
544 } else await ensureLatestDeployments($, state);
545}
546
547// One MainSequence across sessions. Settings, the environment, the screen and Activity live in the plugin
548// store, which every session reads from disk and writes one key at a time under the store's file lock
549// (verified in 2.1.308). A session reads them again when the pane opens or draws, while it is open, and
550// before a MainSequence tool runs; there is no change event. Only the This session card is the session's own.
551const SYNC_INTERVAL_MS = 2000, SYNC_MIN_MS = 1000, SETTINGS_WRITE_MS = 800, VIEW_WRITE_MS = 300, ACTIVITY_WRITE_MS = 500, ACTIVITY_HEARTBEAT_MS = 60000;
552const SIGNALS = ['auth', 'mcp', 'repositories'];
553
554/** Typing settles before the settings reach the other sessions. */
555function shareSettings($, state) {
556 state.settingsTimer?.cancel();
557 state.settingsTimer = $.clock.after(SETTINGS_WRITE_MS, () => { state.settingsTimer = null; void writeSettings($, state).catch(() => {}); });
558}
559async function writeSettings($, state) {
560 // Unknown fields of older plugin versions are kept.
561 const saved = record(await $.store.get('settings'));
562 const value = { ...saved, ...Object.fromEntries(SHARED_SETTINGS.map(key => [key, state.fields[key]])) };
563 await $.store.set('settings', value);
564 state.savedSettings = value; state.shared.settings = JSON.stringify(value);
565}
566
567function shareView($, state) {
568 if (!state.booted || state.closed || state.viewTimer) return;
569 state.viewTimer = $.clock.after(VIEW_WRITE_MS, () => { state.viewTimer = null; void writeView($, state).catch(() => {}); });
570}
571async function writeView($, state) {
572 const view = viewSnapshot(state), json = JSON.stringify(view);
573 if (json === state.shared.view) return;
574 state.shared.view = json;
575 await $.store.set('pane-view', view);
576}
577
578function shareActivity($, state) {
579 if (!state.booted || state.closed || state.activityTimer || !state.sessionId) return;
580 state.activityTimer = $.clock.after(ACTIVITY_WRITE_MS, () => { state.activityTimer = null; void writeActivity($, state).catch(() => {}); });
581}
582/** This session's own operations under its own key, rewritten every minute while one runs so the others can tell it is alive. */
583async function writeActivity($, state, ended = false) {
584 const operations = state.operations.filter(publishedOperation).slice(-30);
585 if (!operations.length && !state.activityPublished) return;
586 const running = operations.some(op => op.status === 'running');
587 if (running && !ended && !state.activityHeartbeat) state.activityHeartbeat = $.clock.every(ACTIVITY_HEARTBEAT_MS, () => { void writeActivity($, state).catch(() => {}); });
588 if ((!running || ended) && state.activityHeartbeat) { state.activityHeartbeat.cancel(); state.activityHeartbeat = null; }
589 const entries = operations.map(op => ended && op.status === 'running' ? { ...activityEntry(op), status: 'cancelled', progress: 'The Claude session ended.' } : activityEntry(op));
590 state.activityPublished = true;
591 await $.store.set(`activity:${state.sessionId}`, { at: await $.clock.now(), session: basename(state.cwd), operations: entries });
592}
593
594/** Tells the other sessions to re-read what this one changed; a session ignores its own. */
595async function signal($, state, name) {
596 await $.store.set(`changed:${name}`, { at: await $.clock.now(), by: state.sessionId, backend: state.fields.backend, environmentUid: state.fields.mcpEnvironment });
597}
598
599/** Polls only while the pane is open; a draw of the pane or the footer also reads the store. */
600function syncSharedPolling($, state) {
601 const wanted = !state.closed && state.paneOpen && state.booted;
602 if (!wanted && state.syncTimer) { const timer = state.syncTimer; state.syncTimer = null; timer.cancel(); state.timers = state.timers.filter(item => item !== timer); }
603 if (wanted && !state.syncTimer) {
604 const timer = $.clock.every(SYNC_INTERVAL_MS, () => { void syncShared($, state); });
605 state.timers.push(timer); state.syncTimer = timer;
606 }
607}
608
609function syncShared($, state, force = false) {
610 if (state.syncing) return state.syncing;
611 if (!state.booted || state.closed || !state.ready || state.initializing || state.preferencesLoading) return Promise.resolve();
612 state.syncing = (async () => {
613 const now = await $.clock.now();
614 if (!force && now - state.syncedAt < SYNC_MIN_MS) return;
615 state.syncedAt = now;
616 await applyShared($, state, now);
617 })().catch(() => {}).finally(() => { state.syncing = null; });
618 return state.syncing;
619}
620
621async function applyShared($, state, now) {
622 let redraw = false;
623 // Settings first: a backend change clears everything that depended on the previous one.
624 const settings = record(await $.store.get('settings')), settingsJson = JSON.stringify(settings);
625 let backendChanged = false;
626 if (settingsJson !== state.shared.settings && !state.settingsTimer) {
627 state.shared.settings = settingsJson; state.savedSettings = settings;
628 for (const key of SHARED_SETTINGS) {
629 if (typeof settings[key] !== 'string' || !settings[key].trim() || settings[key] === state.fields[key]) continue;
630 applyField($, state, key, settings[key]); redraw = true;
631 if (key === 'backend') backendChanged = true;
632 }
633 }
634 if (state.closed) return;
635 if (backendChanged) { await execute($, state, devRequest(state, 'auth-status', { check: false }), true); loadTab($, state); }
636 // The environment of the backend. A load in flight picks the new selection up when it answers.
637 const selections = record(await $.store.get('mcp-environments')), selectionsJson = JSON.stringify(selections);
638 if ((selectionsJson !== state.shared.environments || backendChanged) && !state.closed) {
639 state.environmentSelections = selections;
640 const wanted = typeof selections[state.fields.backend] === 'string' ? selections[state.fields.backend] : '';
641 const listed = state.results['devtools.mcp-environments']?.status === 'success';
642 if (!state.environmentsLoading) {
643 state.shared.environments = selectionsJson;
644 if (wanted !== state.fields.mcpEnvironment) {
645 redraw = true;
646 if (listed && (!wanted || rows(state.results['devtools.mcp-environments'].data).some(row => row.uid === wanted))) await selectEnvironment($, state, wanted, false);
647 else if (wanted) await loadEnvironments($, state);
648 }
649 }
650 }
651 if (state.closed) return;
652 // What another session changed and this one shows: the login, the environment's connections, local checkouts.
653 const signals = {};
654 for (const name of SIGNALS) signals[name] = await $.store.get(`changed:${name}`);
655 const seen = state.shared.signals;
656 state.shared.signals = Object.fromEntries(SIGNALS.map(name => [name, JSON.stringify(signals[name] ?? null)]));
657 // The first read only learns what happened before this session looked.
658 for (const name of seen ? SIGNALS : []) {
659 const value = record(signals[name]);
660 if (seen[name] === state.shared.signals[name] || !value.by || value.by === state.sessionId || value.backend !== state.fields.backend || state.closed) continue;
661 redraw = true;
662 if (name === 'auth') {
663 const result = await execute($, state, devRequest(state, 'auth-status', { check: false }), true);
664 if (record(result.data).authenticated === false || result.error?.code === 'NOT_AUTHENTICATED') clearSignedOut($, state);
665 else if (state.results['devtools.mcp-environments']?.status !== 'success' && (state.environmentSelections[state.fields.backend] || ['Projects', 'Tools', 'Agents'].includes(state.tab))) await loadEnvironments($, state);
666 }
667 if (name === 'mcp' && value.environmentUid === state.fields.mcpEnvironment && state.fields.mcpEnvironment) await refreshTools($, state);
668 if (name === 'repositories' && state.results['projects.local-repositories'] && state.fields.baseDir && state.fields.organization) {
669 await execute($, state, request('projects', 'local-repositories', { root: state.fields.root || state.cwd, baseDir: state.fields.baseDir, organization: state.fields.organization,
670 repositories: rows(state.results['devtools.repositories']?.data).map(row => ({ uid: row.uid, name: row.code_repository_name || row.name })) }), true);
671 }
672 }
673 if (state.closed) return;
674 // The screen, unless this session is about to publish its own.
675 const view = await $.store.get('pane-view'), viewJson = JSON.stringify(view ?? null);
676 if (view && typeof view === 'object' && viewJson !== state.shared.view && !state.viewTimer) {
677 state.shared.view = viewJson; redraw = true;
678 await applyView($, state, view, viewJson);
679 }
680 // Activity: every other session's own key.
681 const published = [];
682 for (const key of await $.store.keys()) {
683 if (!key.startsWith('activity:') || key === `activity:${state.sessionId}`) continue;
684 const entry = record(await $.store.get(key));
685 if (typeof entry.at !== 'number' || now - entry.at > ACTIVITY_KEEP_MS) { await $.store.delete(key); continue; }
686 published.push(entry);
687 }
688 const remote = remoteOperations(published, now);
689 if (JSON.stringify(remote) !== JSON.stringify(state.remoteOperations)) { state.remoteOperations = remote; statusLine($, state); redraw = true; }
690 if (redraw && !state.closed) update($);
691}
692
693const sharedCode = value => value && typeof value === 'object' ? { root: typeof value.root === 'string' ? value.root : '', repositoryUid: String(value.repositoryUid || '') } : null;
694/** Draw what another session shows. Its open details name projects, connections, agents and runs of one backend and
695 * environment, so they apply only while this session has the same selection; the section and searches always do. */
696async function applyView($, state, value, json) {
697 const view = record(value), search = record(view.search), work = [];
698 const searched = new Set(SEARCH_FIELDS.filter(([key, field]) => typeof search[key] === 'string' && search[key] !== state.fields[field]).map(([key, field]) => {
699 state.fields[field] = clean(search[key]).slice(0, 200); return key;
700 }));
701 if (view.codeFilter === 'all' || view.codeFilter === 'local') state.codeFilter = view.codeFilter;
702 const scoped = view.context === context(state), shown = () => state.shared.view === json && view.context === context(state) && !state.closed;
703 const hadProjects = !!state.projectPage, hadAgents = !!state.results['devtools.agents-list'];
704 // The section first: what it loads by itself is not loaded twice below.
705 if (VIEWS.includes(view.tab) && view.tab !== state.tab) selectTab($, state, view.tab);
706 if (scoped) {
707 const code = sharedCode(view.code);
708 if (JSON.stringify(code) !== JSON.stringify(sharedCode(state.codeDetail))) {
709 state.codeDetail = code; state.confirmation = null;
710 if (code) work.push(openCode($, state, code, false)); else syncDeploymentPolling($, state);
711 }
712 const deploy = record(view.deploy), origin = ['home', 'list', 'code'].includes(deploy.origin) ? deploy.origin : 'code';
713 if (deploy.view === 'list' && state.deployView !== 'list') work.push(openDeploymentList($, state, origin));
714 else if (deploy.view === 'detail' && (state.deployView !== 'detail' || state.deployDetail?.uid !== deploy.uid)) work.push(openDeployment($, state, deploy.uid, deploy.kind, origin));
715 else if (!view.deploy && state.deployView) {
716 state.deployView = ''; state.deployHistory = []; state.deployDetail = null; state.deployLogs = null; state.deployStep = ''; syncDeploymentPolling($, state);
717 }
718 const project = record(view.project), projectUid = typeof project.uid === 'string' ? project.uid : '';
719 state.projectListView = !!project.list;
720 if (hadProjects && (searched.has('project') || (Number(state.projectPage?.offset) || 0) !== (Number(project.offset) || 0))) work.push(loadOrganizationProjects($, state, Number(project.offset) || 0));
721 if (projectUid !== state.selectedProject) {
722 if (uuid(projectUid)) work.push(chooseOrganizationProject($, state, projectUid, true));
723 else if (!projectUid) {
724 state.projectDetailRevision++; state.selectedProject = ''; state.projectDetail = null; state.projectWorkspace = null; state.projectOpenMessage = ''; state.projectError = '';
725 }
726 }
727 const tools = record(view.tools);
728 if (tools.view === 'detail' && typeof tools.uid === 'string' && tools.uid && (state.toolsView !== 'detail' || state.selectedMcp !== tools.uid)) work.push(openTool($, state, tools.uid));
729 else if (tools.view === 'add' && state.toolsView !== 'add') { state.toolsView = 'add'; if (!state.results['devtools.mcp-discover'] || searched.has('mcp')) work.push(runDev($, state, 'mcp-discover', { offset: 0 })); }
730 else if (tools.view === 'list' && state.toolsView !== 'list') state.toolsView = 'list';
731 else if (tools.view === 'add' && searched.has('mcp')) work.push(runDev($, state, 'mcp-discover', { offset: 0 }));
732 const agent = record(view.agent), agentUid = typeof agent.uid === 'string' ? agent.uid : '';
733 if (hadAgents && (searched.has('agent') || state.agentOffset !== (Number(agent.offset) || 0))) work.push(loadAgents($, state, Number(agent.offset) || 0));
734 if (!agentUid) state.agentDetail = null;
735 else if (state.agentDetail?.uid !== agentUid && uuid(agentUid)) work.push((async () => {
736 if (!state.agentAccount) await loadAgents($, state, Number(agent.offset) || 0);
737 const result = shown() ? await getAgent($, state, agentUid) : null;
738 if (result && shown()) state.agentDetail = result.agent;
739 })());
740 }
741 update($);
742 await Promise.all(work.map(item => Promise.resolve(item).catch(() => {})));
743}
744
745async function boot($, state, e) {
746 state.closed = false;
747 state.cwd = await $.session.cwd(); state.fields.root = state.cwd;
748 const sessionId = await $.session.id();
749 if (state.sessionId !== sessionId) { state.attachedRepositories = new Set(); state.workspaceAttachment = null; }
750 if (state.sessionId && state.sessionId !== sessionId) clearAgents($, state);
751 state.sessionId = sessionId;
752 const version = await $.session.version();
753 state.version = typeof version === 'string' ? version : version?.version || version?.full || '';
754 // The loader validates every named API. Reading an API method as a value is forbidden.
755 state.ready = meetsVersion(state.version, [2, 1, 286]);
756 state.problem = state.ready ? '' : 'Update Claude Desktop: this mod needs bundled Claude Code 2.1.286+ and streaming process support.';
757 const home = await $.env.get('HOME') || await $.env.get('USERPROFILE');
758 if (typeof home === 'string' && home.trim()) state.fields.baseDir = `${home.replace(/[\\/]+$/, '')}${home.includes('\\') ? '\\' : '/'}mainsequence`;
759 const saved = record(await $.store.get('settings'));
760 state.savedSettings = saved; state.shared.settings = JSON.stringify(saved);
761 state.environmentSelections = record(await $.store.get('mcp-environments')); state.shared.environments = JSON.stringify(state.environmentSelections);
762 for (const key of SHARED_SETTINGS) if (typeof saved[key] === 'string' && saved[key].trim()) state.fields[key] = saved[key];
763 // The section the other sessions show; its details follow once startup has the environment.
764 const view = record(await $.store.get('pane-view'));
765 if (VIEWS.includes(view.tab)) state.tab = view.tab;
766 await $.command.register({ name: 'ms', description: 'Open MainSequence or message an agent by handle', argumentHint: '[@agent <message> | agent <uid> <message>]', immediate: true });
767 // Desktop hosts the engine through the SDK: session.start (and a plugin reload) can name no
768 // surface although the session draws on the desktop. Ask the session which surfaces it has.
769 let desktop = e.surface === 'desktop';
770 if (!desktop) { try { desktop = (await $.session.surfaces()).includes('desktop'); } catch { desktop = false; } }
771 state.initPromise = null;
772 if (desktop) {
773 // Only a person opens the pane. Startup/reloads must not reopen a closed pane.
774 state.timers.push($.clock.after(0, () => { void ensureInitialized($, state); }));
775 }
776 update($);
777}
778
779async function initializeWorkspace($, state) {
780 if (!state.ready || state.closed) { state.preferencesLoading = false; return; }
781 state.initializing = true; update($);
782 void checkPlugin($, state).catch(() => {});
783 try {
784 const preferences = await execute($, state, request('projects', 'workspace-preferences'), true);
785 if (state.closed) return;
786 for (const key of ['backend', 'baseDir']) {
787 if (!state.editedSettings.has(key) && !String(state.savedSettings[key] || '').trim()
788 && typeof preferences.data?.[key] === 'string' && preferences.data[key].trim()) state.fields[key] = preferences.data[key];
789 }
790 state.preferencesLoading = false; update($);
791 await attachWorkspace($, state);
792 await execute($, state, devRequest(state, 'auth-status', { check: false }), true);
793 if ((['Projects', 'Tools', 'Agents'].includes(state.tab) || state.environmentSelections[state.fields.backend]) && !state.results['devtools.mcp-environments']) await loadEnvironments($, state);
794 } finally { state.initializing = false; state.preferencesLoading = false; update($); }
795 await syncShared($, state, true);
796 loadTab($, state);
797}
798
799async function loadRepositories($, state) {
800 if (state.repositoriesLoading) return;
801 state.repositoriesLoading = true; update($);
802 const revision = state.contextRevision;
803 try {
804 const listed = await runDev($, state, 'repositories');
805 if (state.closed || revision !== state.contextRevision || listed?.status !== 'success') return;
806 if (!state.fields.organization && state.fields.backend) await runProject($, state, 'workspace-profile', { backend: state.fields.backend });
807 if (state.closed || revision !== state.contextRevision) return;
808 if (state.fields.baseDir && state.fields.organization) await runProject($, state, 'local-repositories');
809 if (state.closed || revision !== state.contextRevision) return;
810 await ensureLatestDeployments($, state);
811 } finally { state.repositoriesLoading = false; update($); }
812}
813
814function clearOrganizationProjects($, state) {
815 state.projectListRevision++; state.projectDetailRevision++;
816 state.projectPage = null; state.projectDetail = null; state.selectedProject = ''; state.projectWorkspace = null;
817 state.projectError = ''; state.projectOpenMessage = ''; state.projectsLoading = false; state.projectLoading = false; state.projectSetupRunning = false;
818 for (const key of Object.keys(state.results)) if (key.startsWith('devtools.organization-project') || key === 'projects.open-project-workspace') delete state.results[key];
819 for (const op of state.running.values()) if (op.label.startsWith('devtools · organization-project') && op.cancel) void op.cancel().catch(() => {});
820}
821async function loadOrganizationProjects($, state, offset = 0) {
822 if (!state.fields.mcpEnvironment || state.preferencesLoading) return;
823 const revision = ++state.projectListRevision, selection = context(state);
824 state.projectsLoading = true; state.projectError = ''; update($);
825 try {
826 const result = await execute($, state, request('devtools', 'organization-projects', { backend: state.fields.backend,
827 environmentUid: state.fields.mcpEnvironment, search: state.fields.projectSearch, limit: 25, offset }));
828 if (state.closed || selection !== context(state) || revision !== state.projectListRevision) return;
829 if (result.status === 'success') state.projectPage = result.data;
830 else { state.projectPage = null; state.projectError = result.summary; }
831 } finally { if (revision === state.projectListRevision) { state.projectsLoading = false; update($); } }
832}
833/** `shared`: the project another session opened, which need not be on this session's page. */
834async function chooseOrganizationProject($, state, uid, shared = false) {
835 if (!shared && !state.projectPage?.results.some(row => row.uid === uid)) return;
836 const revision = ++state.projectDetailRevision, selection = context(state);
837 state.selectedProject = uid; state.projectSetupRunning = false; state.projectDetail = null; state.projectWorkspace = null; state.projectOpenMessage = ''; state.projectError = ''; state.confirmation = null;
838 delete state.results['devtools.organization-project-setup'];
839 state.projectLoading = true; update($);
840 try {
841 const result = await execute($, state, request('devtools', 'organization-project', { backend: state.fields.backend, environmentUid: state.fields.mcpEnvironment, projectUid: uid }));
842 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision) return;
843 if (result.status === 'success') state.projectDetail = result.data; else state.projectError = result.summary;
844 } finally { if (revision === state.projectDetailRevision) { state.projectLoading = false; update($); } }
845}
846async function openProjectLink($, state, target, branchUid) {
847 if (!state.projectDetail) return;
848 const revision = state.projectDetailRevision, selection = context(state);
849 const result = await execute($, state, request('devtools', 'organization-project-link', { backend: state.fields.backend,
850 environmentUid: state.fields.mcpEnvironment, projectUid: state.selectedProject, target, branchUid, commandCenterUrl: state.fields.commandCenterUrl }));
851 if (!state.closed && revision === state.projectDetailRevision && selection === context(state) && result.status !== 'success') { state.projectError = result.summary; update($); }
852}
853function confirmOrganizationSetup($, state) {
854 if (!state.projectDetail || state.projectSetupRunning || !state.fields.baseDir) return;
855 const revision = state.projectDetailRevision, selection = context(state);
856 const input = { backend: state.fields.backend, environmentUid: state.fields.mcpEnvironment, projectUid: state.selectedProject, baseDir: state.fields.baseDir };
857 state.confirmation = { title: 'Set up project locally', detail: `Prepare missing repositories, create or reuse ${state.projectDetail.project.name}'s local Claude workspace under ${input.baseDir}, and select each assigned branch. Existing changes are preserved.`,
858 callback: async () => {
859 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision || input.baseDir !== state.fields.baseDir) return;
860 state.projectSetupRunning = true; state.projectOpenMessage = ''; update($);
861 try {
862 const result = await execute($, state, request('devtools', 'organization-project-setup', input));
863 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision) return;
864 state.results['devtools.organization-project-setup'] = publicValue(result);
865 if (result.status === 'success') { void signal($, state, 'repositories').catch(() => {}); state.projectWorkspace = result.data; await openOrganizationWorkspace($, state); }
866 } finally { if (revision === state.projectDetailRevision) { state.projectSetupRunning = false; update($); } }
867 } };
868 update($);
869}
870async function openOrganizationWorkspace($, state) {
871 const workspace = state.projectWorkspace;
872 if (!workspace || workspace.projectUid !== state.selectedProject || workspace.environmentUid !== state.fields.mcpEnvironment) return;
873 const revision = state.projectDetailRevision, selection = context(state);
874 const prepared = await execute($, state, request('projects', 'read-project-workspace', { root: workspace.root,
875 backend: state.fields.backend, environmentUid: workspace.environmentUid, projectUid: workspace.projectUid }));
876 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision) return;
877 if (prepared.status !== 'success') { state.projectOpenMessage = prepared.summary; update($); return; }
878 // Store the exact membership selected by the user, not an instruction from an
879 // arbitrary folder's manifest. Only that workspace may attach these paths later.
880 const binding = workspaceBinding(prepared.data);
881 await $.store.set(`project-workspace:${workspace.root}`, binding);
882 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision) return;
883 if (state.cwd === workspace.root) { await attachWorkspace($, state); return; }
884 const result = await execute($, state, request('projects', 'open-project-workspace', { root: workspace.root,
885 backend: state.fields.backend, environmentUid: workspace.environmentUid, projectUid: workspace.projectUid }));
886 if (state.closed || selection !== context(state) || revision !== state.projectDetailRevision) return;
887 state.projectOpenMessage = result.status === 'success' ? 'Checkouts prepared. Start the Code session in Claude; MainSequence will add every project repository there.' : result.summary; update($);
888}
889
890export async function attachProjectRepositories($, workspace, attached, isCurrent, progress) {
891 workspaceBinding(workspace);
892 for (const row of workspace.repositories) {
893 if (!isCurrent()) return false;
894 if (attached.has(row.path)) continue;
895 progress(`Adding ${row.name} (${row.branch}) to ${workspace.name}…`);
896 const result = await $.command.run({ command: 'add-dir', args: row.path });
897 if (!isCurrent()) return false;
898 const text = String(result?.text || '').replace(/\x1b\[[0-9;]*m/g, '');
899 // /add-dir returns text for errors too. A completed command is not itself
900 // proof of registration; a descendant "already covered" response is not one.
901 if ((result.exitCode !== undefined && result.exitCode !== 0) || !text.includes(row.path)
902 || !(/Added .+ as a working directory/.test(text) || /is already added as a working directory\./.test(text))) {
903 throw new Error(`Claude could not attach ${row.name}. ${text || 'The add-dir command returned no confirmation.'}`);
904 }
905 attached.add(row.path);
906 }
907 return true;
908}
909
910async function attachWorkspace($, state) {
911 if (state.workspaceAttachment?.running || state.closed || !state.ready) return;
912 const sessionId = state.sessionId, root = state.cwd;
913 const isCurrent = () => !state.closed && state.sessionId === sessionId && state.cwd === root;
914 const saved = await $.store.get(`project-workspace:${root}`);
915 if (!saved || saved.root !== root || !isCurrent()) return;
916 state.workspaceAttachment = { running: true, error: false, message: 'Checking project repositories…' }; update($);
917 try {
918 const result = await execute($, state, request('projects', 'read-project-workspace', { root, backend: saved.backend,
919 environmentUid: saved.environmentUid, projectUid: saved.projectUid }), true);
920 if (!isCurrent()) return;
921 if (result.status !== 'success') throw new Error(result.summary);
922 const workspace = result.data;
923 if (JSON.stringify(workspaceBinding(workspace)) !== JSON.stringify(saved)) throw new Error('Project membership changed. Run Set up project locally again to review the new repositories.');
924 const complete = await attachProjectRepositories($, workspace, state.attachedRepositories, isCurrent, message => {
925 state.workspaceAttachment.message = message; update($);
926 });
927 if (complete) state.workspaceAttachment = { running: false, error: false,
928 message: `${workspace.name}: ${workspace.repositories.length} repository checkouts attached to this Claude session.` };
929 } catch (error) {
930 if (isCurrent()) state.workspaceAttachment = { running: false, error: true, message: String(error.message || error) };
931 } finally { if (isCurrent()) { state.workspaceAttachment.running = false; update($); } }
932}
933
934export function register(on, options = {}) {
935 const state = initialState(options);
936 on('session.start', async ($, e, next) => { await ensureBoot($, state, e); return next(e); });
937 on('command.run', { command: 'ms' }, ($, e) => agentCommand($, state, e)).catch(() => ({ text: 'The agent operation could not complete. Check Activity before sending again.' }));
938 on('prompt.submit', { text: /^\/ms\s+\S/ }, ($, e, next) => e.attachments?.length ? { drop: 'MainSequence agent commands accept text only. Keep attachments in ordinary Claude chat.' } : next(e));
939 // This site belongs to the session footer, so closing our pane does not remove it.
940 // Keep the engine/other plugins' footer content and leave terminal rendering alone.
941 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
942 if (e.surface !== 'desktop') return next(e);
943 wakeOnDesktop($, state, e);
944 const existing = await next(e);
945 const ui = $.ui.resolve(e), { Box, Button, Svg } = ui;
946 return Box({ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'flex-end', gap: 1,
947 children: [existing, Box({ key: 'mainsequence-launcher-group', flexDirection: 'row', alignItems: 'center', gap: 1,
948 children: [Svg({ source: EMBLEM, alt: 'MainSequence emblem', width: 18, height: 18 }),
949 Button({ key: 'mainsequence-launcher', label: 'MainSequence', onPress: () => {
950 void openPane($, state).catch(() => {
951 $.ui.toast('MainSequence could not open. Try /ms or reload the plugin.');
952 });
953 } })] })].filter(Boolean) });
954 });
955 // SessionMode is flattened into a narrow text slot by Desktop. Use the native
956 // composer band for the selected agent's image and readable activity instead.
957 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
958 wakeOnDesktop($, state, e);
959 const existing = await next(e);
960 if (e.surface !== 'desktop' || !current(state, state.activeAgent)) return existing;
961 const ui = $.ui.resolve(e);
962 return ui.Box({ flexDirection: 'column', children: [existing,
963 ui.Box({ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 1, children: [renderAgentActivity(ui, state, state.activeAgent),
964 ui.Box({ flexDirection: 'row', flexShrink: 0, alignItems: 'center', gap: 1, children: [
965 ui.Button({ key: 'agent-switch', label: 'Switch agent', plain: true, dimColor: true, onPress: () => {
966 state.tab = 'Agents'; state.agentDetail = null; update($); shareView($, state);
967 void openPane($, state).catch(() => {});
968 } }),
969 ui.Button({ key: 'agent-end', label: 'End', plain: true, dimColor: true, onPress: () => { void endAgentConversation($, state).catch(() => {}); } }),
970 ] })] })].filter(Boolean) });
971 });
972 // Desktop discovers render sites from static hook metadata before invoking JS.
973 // Imported constants become unknown matcher values in that discovery pass.
974 on('ui.render', { component: 'Pane', requestId: 'mainsequence' }, async ($, e, next) => {
975 if (e.surface !== 'desktop') return next(e);
976 const ui = $.ui.resolve(e), actions = actionsFor($, state);
977 wakeOnDesktop($, state, e);
978 // A pane draw proves the pane is open: remote-run polling resumes for the section shown.
979 if (!state.paneOpen) { state.paneOpen = true; try { syncDeploymentPolling($, state); syncAgentIconPolling($, state); syncSharedPolling($, state); } catch { /* polling is optional */ } }
980 if (state.paneFault) return renderRecovery(ui, state, actions);
981 try { return renderPane(ui, state, actions, { placement: e.props?.placement }); }
982 catch { state.paneFault = { phase: 'render', element: '' }; return renderRecovery(ui, state, actions); }
983 });
984 on('ui.close', { id: 'mainsequence' }, ($, e, next) => {
985 // Nothing polls a remote run while the pane is closed; reopening it resumes.
986 state.paneOpen = false; cancelDeployTimer(state, 'run'); cancelDeployTimer(state, 'list'); syncAgentIconPolling($, state); syncSharedPolling($, state);
987 return next(e);
988 });
989 on('ui.fault', { component: 'Pane', requestId: 'mainsequence' }, ($, e, next) => {
990 if (e.surface === 'desktop' && !state.paneFault) {
991 state.paneFault = { phase: clean(e.phase), element: clean(e.element) };
992 update($);
993 }
994 return next(e);
995 });
996 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
997 if (e.surface !== 'desktop' || e.props.command !== 'ms') return next(e);
998 const text = e.props.text, prefix = `${$.plugin.name}: `;
999 // Desktop prefixes plugin command output before asking this render site.
1000 // Match only an exact output recorded here, with that one engine prefix.
1001 const binding = state.agentCommandOutputs.get(state.agentCommandOutputs.has(text) ? text
1002 : typeof text === 'string' && text.startsWith(prefix) ? text.slice(prefix.length) : text);
1003 if (!current(state, binding)) return next(e);
1004 const existing = await next(e), ui = $.ui.resolve(e);
1005 return ui.Box({ flexDirection: 'row', alignItems: 'flex-start', gap: 1, children: [
1006 renderAgentIcon(ui, state, binding.agent.uid, { animate: false }), existing,
1007 ].filter(Boolean) });
1008 });
1009 for (const component of ['ToolUse', 'ToolResult']) on('ui.render', { component }, async ($, e, next) => {
1010 const entry = state.agentTools[e.props.tool];
1011 if (e.surface !== 'desktop' || !current(state, entry?.binding)) return next(e);
1012 const existing = await next(e), ui = $.ui.resolve(e), active = component === 'ToolUse' && e.props.isRunning === true;
1013 return ui.Box({ flexDirection: 'row', alignItems: 'flex-start', gap: 1, children: [
1014 renderAgentIcon(ui, state, entry.binding.agent.uid, { animate: active,
1015 activity: active ? agentIconActivity(entry.binding) : null }), existing,
1016 ].filter(Boolean) });
1017 });
1018 on('tool.call', { tool: /^mcp__mainsequence__/ }, async ($, e) => {
1019 if (e.tool.startsWith('mcp__mainsequence__ms_agent_')) return handleAgentTool($, state, e);
1020 if (e.tool === `mcp__mainsequence__${DEPLOY_TOOL}`) return deploymentStatusTool($, state, e);
1021 // A session in the background follows an environment chosen in another one before its tools run.
1022 await syncShared($, state);
1023 let entry = state.mcpTools[e.tool];
1024 // After a reload or right at startup the catalog still names tools this instance has not
1025 // registered yet: finish startup and the in-flight registration before refusing.
1026 if (!entry) { await toolsReady($, state); entry = state.mcpTools[e.tool]; }
1027 if (!entry || entry.environmentUid !== state.fields.mcpEnvironment) return { deny: 'This MainSequence MCP tool is no longer connected. Refresh MainSequence tools.' };
1028 const revision = state.contextRevision;
1029 const answer = await $.ui.ask(`Run MainSequence tool ${entry.tool} in ${environmentLabel(state)}?`, ['Run tool', 'Cancel']);
1030 if (answer !== 'Run tool') return { deny: 'The user cancelled this MainSequence action.' };
1031 if (state.closed || revision !== state.contextRevision || state.mcpTools[e.tool] !== entry) return { deny: 'The selected environment changed. Review the tool in the current environment.' };
1032 const { tool, tool_use_id, agentId, ...arguments_ } = e;
1033 // mcp-call answered in the shared result shape (devtools ADR 0004); the blocks are converted below.
1034 const result = await execute($, state, request('devtools', 'mcp-tool-call', { ...entry, arguments: arguments_ }));
1035 return toolResult(result);
1036 }).catch(() => ({ deny: 'MainSequence could not confirm or run this tool call.' }));
1037 on('session.end', async ($, e, next) => {
1038 // Edits still waiting to be shared, and this session's Activity with nothing left running.
1039 const settings = !!state.settingsTimer, view = !!state.viewTimer;
1040 for (const timer of [state.settingsTimer, state.viewTimer, state.activityTimer, state.activityHeartbeat]) timer?.cancel();
1041 state.settingsTimer = state.viewTimer = state.activityTimer = null;
1042 if (settings) await writeSettings($, state).catch(() => {});
1043 if (view) await writeView($, state).catch(() => {});
1044 if (state.booted && state.sessionId) await writeActivity($, state, true).catch(() => {});
1045 state.closed = true;
1046 for (const op of state.running.values()) if (op.status === 'running' && op.cancel) void op.cancel().catch(() => {});
1047 for (const timer of state.timers) timer.cancel();
1048 return next(e);
1049 });
1050}
1051
1052
1053/** The engine's streaming process API takes input, not stdin (verified in 2.1.295). */
1054export async function runWorker($, state, envelope, operation, onProgress) {
1055 const input = JSON.stringify(envelope);
1056 if (input.length > 1_000_000) return fail(envelope.request.request.action, 'The request is too large.', 'INVALID_REQUEST');
1057 let child;
1058 let timer;
1059 let pending = '';
1060 let bytes = 0;
1061 let answer;
1062 let invalid = false;
1063 let timedOut = false;
1064 try {
1065 child = $.process.spawn({ argv: [state.fields.nodePath, `${$.plugin.root}/dist/claudeRunner.js`], cwd: state.cwd, input });
1066 operation.cancel = () => { operation.cancelled = true; return child.return({ code: null, signal: 'SIGTERM' }); };
1067 timer = $.clock.after(1_800_000, () => { timedOut = true; return child.return({ code: null, signal: 'SIGTERM' }); });
1068 for await (const chunk of child) {
1069 if (chunk.stream !== 'stdout') continue; // Child stderr is private and never rendered.
1070 bytes += chunk.text.length;
1071 if (bytes > 4_000_000) { invalid = true; break; }
1072 pending += chunk.text;
1073 let newline;
1074 while ((newline = pending.indexOf('\n')) >= 0) {
1075 const line = pending.slice(0, newline); pending = pending.slice(newline + 1);
1076 if (!line.trim()) continue;
1077 let message;
1078 try { message = JSON.parse(line); } catch { invalid = true; break; }
1079 if (message.id !== undefined && message.id !== envelope.id) { invalid = true; break; }
1080 if (message.type === 'progress' && typeof message.message === 'string' && !answer) onProgress(clean(message.message));
1081 else if (message.type === 'result' && !answer && message.result?.schemaVersion === 1
1082 && ['success', 'failure', 'cancelled'].includes(message.result.status) && typeof message.result.summary === 'string') answer = message.result;
1083 else { invalid = true; break; }
1084 }
1085 if (invalid) break;
1086 }
1087 const exit = await child.result;
1088 if (answer && !invalid && !pending.trim() && exit.code === 0) return answer;
1089 } catch { /* Only a fixed error reaches the UI; exceptions can hold private child output. */ }
1090 finally { timer?.cancel(); operation.cancel = undefined; }
1091 const action = envelope.request.request.action;
1092 return operation.cancelled ? fail(action, 'The operation was cancelled.', 'CANCELLED', true)
1093 : timedOut ? fail(action, 'The operation timed out.', 'PROCESS_TIMEOUT', true)
1094 : fail(action, 'The MainSequence worker did not report a valid result. Check Node 22+ and the plugin installation.', 'PROCESS_FAILED', true);
1095}
1096
1097// Native host effect analysis follows $ only through functions in this module.
1098
1099const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
1100
1101// Deployments: remote state read from the environment's deployment history. Reads run quietly,
1102// outside Activity and the running-operations counter, which count local operations only.
1103const DEPLOY_ACTIONS = ['deployment-history', 'deployment-run', 'deployment-logs'];
1104const DEPLOY_TOOL = 'ms_deployment_status';
1105const DEPLOY_POLL_LIMIT = 360;
1106
1107function cancelDeployTimer(state, slot) {
1108 const entry = state.deployTimers[slot];
1109 if (!entry) return;
1110 entry.timer.cancel();
1111 state.timers = state.timers.filter(timer => timer !== entry.timer);
1112 state.deployTimers[slot] = null;
1113}
1114function clearDeployments($, state) {
1115 cancelDeployTimer(state, 'run'); cancelDeployTimer(state, 'list');
1116 state.deployRevision++; state.deployView = ''; state.deployOrigin = 'code'; state.deployHistory = []; state.deployList = null; state.deployListLoading = false; state.deployListError = '';
1117 state.deployDetail = null; state.deployStep = ''; state.deployLogs = null; state.deployLogsLoading = false; state.deployLatest = {}; state.deployPolls = 0; state.deployPaused = false;
1118 for (const op of state.running.values()) if (DEPLOY_ACTIONS.some(action => op.label === `projects · ${action}`) && op.cancel) void op.cancel().catch(() => {});
1119}
1120/** The branch uids whose latest run the current section draws. */
1121function deployTargets(state) {
1122 if (!state.fields.mcpEnvironment) return [];
1123 const targets = [];
1124 if (state.tab === 'Home' && (!state.fields.root || state.fields.root === state.cwd)) targets.push(deployTarget(state, { root: state.cwd }));
1125 if (state.tab === 'Code' && !state.deployView && state.codeDetail?.root) targets.push(deployTarget(state, state.codeDetail));
1126 return [...new Set(targets.map(target => target.branchUid).filter(Boolean))];
1127}
1128async function loadLatestDeployment($, state, branchUid) {
1129 const selection = context(state), revision = state.deployRevision, previous = state.deployLatest[branchUid];
1130 state.deployLatest[branchUid] = { entry: previous?.entry || null, loading: true, error: '' }; update($);
1131 const result = await execute($, state, request('projects', 'deployment-history', { backend: state.fields.backend,
1132 environmentUid: state.fields.mcpEnvironment, codeRepositoryBranchUid: branchUid, limit: 1, offset: 0 }), true);
1133 if (state.closed || selection !== context(state) || revision !== state.deployRevision) return;
1134 state.deployLatest[branchUid] = result.status === 'success' ? { entry: rows(result.data)[0] || null, loading: false, error: '', now: result.data.now }
1135 : { entry: previous?.entry || null, loading: false, error: result.summary };
1136 syncDeploymentPolling($, state); update($);
1137}
1138async function ensureLatestDeployments($, state, force = false) {
1139 for (const branchUid of deployTargets(state)) {
1140 const existing = state.deployLatest[branchUid];
1141 if (existing?.loading || (existing && !force)) continue;
1142 await loadLatestDeployment($, state, branchUid);
1143 }
1144 syncDeploymentPolling($, state);
1145}
1146async function loadDeploymentList($, state, { offset = 0, limit = 25 } = {}) {
1147 if (!state.fields.mcpEnvironment) return;
1148 const selection = context(state), revision = state.deployRevision;
1149 state.deployListLoading = true; update($);
1150 try {
1151 const result = await execute($, state, request('projects', 'deployment-history', { backend: state.fields.backend,
1152 environmentUid: state.fields.mcpEnvironment, limit: Math.max(1, Math.min(100, limit)), offset }), true);
1153 if (state.closed || selection !== context(state) || revision !== state.deployRevision) return;
1154 if (result.status !== 'success') { state.deployListError = result.summary; return; }
1155 const kept = offset > 0 && state.deployList ? state.deployList.entries.slice(0, offset) : [];
1156 const seen = new Set(kept.map(entry => entry.uid));
1157 state.deployList = { count: result.data.count, now: result.data.now, entries: [...kept, ...rows(result.data).filter(entry => !seen.has(entry.uid))] };
1158 state.deployListError = '';
1159 } finally { if (revision === state.deployRevision) { state.deployListLoading = false; syncDeploymentPolling($, state); update($); } }
1160}
1161async function openDeploymentList($, state, origin) {
1162 if (!state.fields.mcpEnvironment) { $.ui.toast('Choose an organization environment to see deployments.'); return; }
1163 cancelDeployTimer(state, 'run');
1164 state.tab = 'Code'; state.deployView = 'list'; state.deployOrigin = origin === 'home' ? 'home' : 'code'; state.deployHistory = []; state.confirmation = null;
1165 state.deployDetail = null; state.deployLogs = null; state.deployStep = ''; state.deployPolls = 0; state.deployPaused = false;
1166 update($);
1167 await loadDeploymentList($, state, { offset: 0, limit: Math.max(25, state.deployList?.entries.length || 0) });
1168}
1169function closeDeployments($, state) {
1170 const origin = state.deployOrigin;
1171 state.deployView = ''; state.deployHistory = []; state.deployDetail = null; state.deployLogs = null; state.deployStep = ''; state.confirmation = null;
1172 if (origin === 'home') state.tab = 'Home';
1173 syncDeploymentPolling($, state); update($);
1174 void ensureLatestDeployments($, state).catch(() => {});
1175}
1176async function openDeployment($, state, uid, kind, origin) {
1177 if (!uuid(uid) || !state.fields.mcpEnvironment) return;
1178 if (origin === 'detail' && state.deployDetail) state.deployHistory.push({ uid: state.deployDetail.uid, kind: state.deployDetail.kind, step: state.deployStep });
1179 else if (origin !== 'back') { state.deployHistory = []; state.deployOrigin = origin === 'list' ? 'list' : origin === 'home' ? 'home' : 'code'; }
1180 if (state.deployHistory.length > 10) state.deployHistory.shift();
1181 cancelDeployTimer(state, 'run'); cancelDeployTimer(state, 'list');
1182 state.tab = 'Code'; state.deployView = 'detail'; state.confirmation = null;
1183 state.deployDetail = { uid: uid.toLowerCase(), kind: kind === 'workflow_run' ? 'workflow_run' : 'deployment_run', run: null, hidden: false, error: '', loading: true, now: null, ticket: 0 };
1184 state.deployStep = ''; state.deployLogs = null; state.deployLogsLoading = false; state.deployPolls = 0; state.deployPaused = false;
1185 update($);
1186 await loadDeploymentDetail($, state);
1187}
1188async function deployBack($, state) {
1189 const previous = state.deployHistory.pop();
1190 if (previous) { await openDeployment($, state, previous.uid, previous.kind, 'back'); if (state.deployDetail?.uid === previous.uid) await selectDeployStep($, state, previous.step); return; }
1191 if (state.deployOrigin === 'list') {
1192 cancelDeployTimer(state, 'run');
1193 state.deployView = 'list'; state.deployDetail = null; state.deployLogs = null; state.deployStep = ''; update($);
1194 await loadDeploymentList($, state, { offset: 0, limit: Math.max(25, state.deployList?.entries.length || 0) });
1195 return;
1196 }
1197 closeDeployments($, state);
1198}
1199async function loadDeploymentDetail($, state) {
1200 const detail = state.deployDetail;hooks/model.mjs 140 lines1export const PANE = 'mainsequence';
2export const TABS = ['Home', 'Code', 'Projects', 'Agents', 'Tools'];
3export const VIEWS = [...TABS, 'Settings', 'Activity'];
4export const basename = value => String(value || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || String(value || '');
5export const clean = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, 16000);
6export const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7export const rows = value => Array.isArray(value) ? value : Array.isArray(value?.results) ? value.results : Array.isArray(value?.items) ? value.items : [];
8export const request = (scope, action, values = {}) => ({ scope, request: { action, ...values } });
9// Branches share a clone destination, so a second branch cannot run concurrently.
10export const setupKey = values => JSON.stringify([values.backend?.trim() || '', String(values.repositoryUid || ''), values.baseDir]);
11// Claude rejects the entire pane when a Select has more than 64 options.
12// Reserve one slot for the placeholder or the selection on another page.
13export function selectPage(options, value, requestedPage = 0) {
14 const seen = new Set();
15 const entries = options.filter(option => typeof option.value === 'string' && !seen.has(option.value) && seen.add(option.value));
16 const size = 63, count = Math.ceil(entries.length / size);
17 const page = Math.max(0, Math.min(Number.isInteger(requestedPage) ? requestedPage : 0, count - 1));
18 const visible = entries.slice(page * size, (page + 1) * size);
19 const selected = entries.find(option => option.value === value);
20 return { entries: visible, selected, page, count, total: entries.length, start: page * size + 1, end: page * size + visible.length };
21}
22/** The composer handle for an agent: `/ms @back-office-helper …` names its target. */
23export const agentHandle = name => clean(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'agent';
24export function mcpDisplayName(value) {
25 const name = [value?.display_name, value?.name].find(name => typeof name === 'string' && clean(name).trim());
26 return name ? clean(name).trim() : 'Unnamed MCP';
27}
28/** Settings every MainSequence session shares. The session's own folder (`root`) is never one of them. */
29export const SHARED_SETTINGS = ['backend', 'baseDir', 'organization', 'nodePath', 'commandCenterUrl'];
30export const SEARCH_FIELDS = [['project', 'projectSearch'], ['agent', 'agentSearch'], ['code', 'codeSearch'], ['mcp', 'mcpSearch']];
31/** The screen every session draws alike: the section, its open details and searches. The This session card is not in it. */
32export function viewSnapshot(state) {
33 const f = state.fields, code = state.codeDetail, detail = state.deployDetail;
34 return { tab: state.tab, context: `${f.backend}\n${f.mcpEnvironment}`,
35 search: Object.fromEntries(SEARCH_FIELDS.map(([key, field]) => [key, f[field] || ''])), codeFilter: state.codeFilter,
36 code: code ? { root: code.root || '', repositoryUid: String(code.repositoryUid || '') } : null,
37 deploy: state.deployView === 'list' ? { view: 'list', uid: '', kind: '', origin: state.deployOrigin }
38 : state.deployView === 'detail' && detail ? { view: 'detail', uid: detail.uid, kind: detail.kind, origin: state.deployOrigin } : null,
39 project: { uid: state.selectedProject || '', list: !!state.projectListView, offset: Number(state.projectPage?.offset) || 0 },
40 tools: { view: state.toolsView, uid: state.toolsView === 'detail' ? state.selectedMcp || '' : '' },
41 agent: { uid: state.agentDetail?.uid || '', offset: state.agentOffset || 0 } };
42}
43// Reads draw the session's own screen; every other operation is published to the other sessions' Activity.
44const READ_ACTIONS = new Set(['auth-status', 'repositories', 'status', 'sdk-status', 'mcp-environments', 'mcp-discover', 'mcp-list', 'mcp-tools', 'mcp-catalog',
45 'organization-projects', 'organization-project', 'local-repositories', 'workspace-profile', 'workspace-preferences', 'runtime-diagnostics', 'read-project-workspace',
46 'pick-folder', 'delete-local-preview', 'docker-preview', 'devcontainer-preview']);
47export const ACTIVITY_STALE_MS = 180000, ACTIVITY_KEEP_MS = 7 * 86400000;
48const ACTIVITY_STATES = new Set(['running', 'success', 'failure', 'cancelled']);
49export const publishedOperation = op => !READ_ACTIONS.has(String(op.label).split(' · ').pop());
50/** What a session publishes of one operation: the row Activity draws, never result data. */
51export function activityEntry(op) {
52 const result = op.result && { status: op.result.status, summary: clean(op.result.summary).slice(0, 500),
53 ...(op.result.effects?.message ? { effects: { message: clean(op.result.effects.message).slice(0, 500) } } : {}) };
54 return { id: clean(op.id), title: clean(op.title), context: clean(op.context).slice(0, 300), label: clean(op.label), status: op.status,
55 progress: clean(op.progress).slice(0, 500), startedAt: Number(op.startedAt) || 0,
56 ...(op.setupTarget ? { setupTarget: op.setupTarget, destination: clean(op.destination) } : {}), ...(result ? { result } : {}) };
57}
58/** The other sessions' published operations, oldest first. A running one whose session stopped reporting is drawn as interrupted. */
59export function remoteOperations(published, now) {
60 const operations = [];
61 for (const { session, at, operations: entries } of published) {
62 const stale = now - at > ACTIVITY_STALE_MS;
63 for (const entry of rows(entries)) {
64 if (!entry || typeof entry.id !== 'string' || !ACTIVITY_STATES.has(entry.status)) continue;
65 operations.push({ ...entry, startedAt: Number(entry.startedAt) || 0, remote: true, session: clean(session),
66 ...(stale && entry.status === 'running' ? { status: 'stale', progress: 'Stopped reporting: its Claude session may have closed.' } : {}) });
67 }
68 }
69 return operations.sort((a, b) => a.startedAt - b.startedAt).slice(-50);
70}
71/** This session's operations and the other sessions', in the order they started. */
72export function allOperations(state) {
73 const order = op => op.startedAt || Number.MAX_SAFE_INTEGER;
74 return [...state.remoteOperations, ...state.operations].sort((a, b) => order(a) - order(b));
75}
76export function initialState(options = {}) {
77 return { tab: 'Home', expanded: {}, initPromise: null, toolRefresh: null, mcpNames: {}, booting: null, booted: false, paneFault: null, selectPages: {}, codeDetail: null, codeFilter: 'all', toolsView: 'list', projectListView: false, homeStatusRequested: false, contextRevision: 0, initializing: false, repositoriesLoading: false, cwd: '', sessionId: '', ready: false, problem: '', version: '', closed: false,
78 // Shared with the other sessions through the plugin store: the JSON each key last held here, and the writers' timers.
79 shared: { settings: '', environments: '', view: '', signals: null }, remoteOperations: [], syncing: null, syncedAt: 0, syncTimer: null,
80 settingsTimer: null, viewTimer: null, activityTimer: null, activityHeartbeat: null, activityPublished: false,
81 fields: { backend: 'https://api.main-sequence.app', commandCenterUrl: '', projectSearch: '', root: '', baseDir: '', organization: '', repositoryUid: '', branch: '', projectType: '',
82 image: '', platform: '', mcpSearch: '', mcpEnvironment: '', mcpTool: '', mcpArguments: '{}', agentSearch: '', codeSearch: '',
83 nodePath: typeof options.nodePath === 'string' && options.nodePath ? options.nodePath : 'node' },
84 projectPage: null, projectDetail: null, selectedProject: '', projectWorkspace: null, projectError: '', projectOpenMessage: '', projectsLoading: false, projectLoading: false, projectSetupRunning: false, projectListRevision: 0, projectDetailRevision: 0,
85 workspaceAttachment: null, attachedRepositories: new Set(),
86 editedSettings: new Set(), savedSettings: {}, environmentsLoading: false, environmentSelections: {}, preferencesLoading: false, operations: [], results: {}, selectedRepository: '', selectedLocal: '', selectedMcp: '',
87 agentRevision: 0, agentListRevision: 0, agentOffset: 0, agentDetail: null, agentAccount: '', agentTools: {}, activeAgent: null, agentsLoading: false, agentSelection: 0, agentPreparing: {}, agentBindings: {}, agentDrafts: {}, agentSave: Promise.resolve(),
88 agentIcons: {}, agentIconLoads: new Map(), agentIconTimes: {}, agentIconTimer: null, agentIconRetry: null, agentIconPolls: 0, agentCommandOutputs: new Map(),
89 // Deployments: remote state read from the environment's deployment history, never a local operation.
90 deployView: '', deployOrigin: 'code', deployHistory: [], deployList: null, deployListLoading: false, deployListError: '',
91 deployDetail: null, deployStep: '', deployLogs: null, deployLogsLoading: false, deployLatest: {}, deployRevision: 0,
92 deployTimers: { run: null, list: null }, deployPolls: 0, deployPaused: false, paneOpen: false,
93 // The plugin's own release: the worker's answer to plugin-version, merged with plugin-update's.
94 plugin: {}, pluginCheck: null, pluginCheckedAt: 0, pluginUpdating: false, pluginHelp: '',
95 confirmation: null, sequence: 0, timers: [], mcpTools: {}, mcpOffset: 0, running: new Map() };
96}
97/** The MainSequence repository branch a local folder stands for, from the repositories list and the checked status. */
98export function deployTarget(state, { root = '', repositoryUid = '' } = {}) {
99 const remote = rows(state.results['devtools.repositories']?.data);
100 const locals = rows(state.results['projects.local-repositories']?.data);
101 const context = record(record(state.results['devtools.status']?.data).project);
102 const trailing = /-([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i.exec(basename(root));
103 const uid = String(repositoryUid || locals.find(local => local.path === root)?.repositoryUid || (trailing ? trailing[1] : '') || '').toLowerCase();
104 const repository = remote.find(row => String(row.uid).toLowerCase() === uid);
105 const branches = rows(repository?.branches);
106 // The checked status describes the operation target; a single registered branch needs no check.
107 const branchName = (root && state.fields.root === root && context.branch) || (branches.length === 1 ? branches[0].repository_branch : '')
108 || (state.fields.repositoryUid === uid ? state.fields.branch : '') || '';
109 const branch = branches.find(row => row.repository_branch === branchName);
110 return { repositoryUid: uid, repositoryName: repository?.code_repository_name || '', branchName, branchUid: branch?.uid ? String(branch.uid) : '' };
111}
112export function meetsVersion(value, minimum = [2, 1, 296]) {
113 const match = String(value || '').match(/(\d+)\.(\d+)\.(\d+)/);
114 if (!match) return false;
115 const parts = match.slice(1).map(Number);
116 for (let i = 0; i < 3; i++) { if (parts[i] !== minimum[i]) return parts[i] > minimum[i]; }
117 return true;
118}
119export function publicValue(value, depth = 0) {
120 if (depth > 8) return '[nested content]';
121 if (Array.isArray(value)) return value.slice(0, 100).map(item => publicValue(item, depth + 1));
122 if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
123 .filter(([key]) => !/^(accessToken|refreshToken|access|refresh|token|password|secret|credentialReference|credentialRef|projection)$/i.test(key))
124 .map(([key, item]) => [clean(key), publicValue(item, depth + 1)]));
125 return typeof value === 'string' ? clean(value) : value;
126}
127export function resultText(result) {
128 return clean([result?.summary, result?.effects?.message,
129 result?.data === undefined ? '' : JSON.stringify(publicValue(result.data), null, 2)].filter(Boolean).join('\n\n'));
130}
131export function fail(action, message, code = 'PROCESS_FAILED', uncertain = false) {
132 return { schemaVersion: 1, action, status: code === 'CANCELLED' ? 'cancelled' : 'failure', summary: message,
133 error: { code, message }, ...(uncertain ? { effects: { state: 'partial-or-uncertain', message: 'The operation stopped before reporting completion. Inspect changes before retrying.' } } : {}) };
134}
135export function actionOptions(state) {
136 const f = state.fields;
137 return { ...(f.backend.trim() ? { backend: f.backend.trim() } : {}), root: f.root || state.cwd,
138 ...(f.projectType ? { projectType: f.projectType } : {}) };
139}
140hooks/view.mjs 398 lines1import { TABS, clean, record, rows, resultText, mcpDisplayName, basename, setupKey, deployTarget, allOperations } from './model.mjs';
2import { primitives, OPERATION_STATUS, OPERATION_KIND } from './primitives.mjs';
3import { renderAgents } from './agentView.mjs';
4import { renderProjects } from './projectView.mjs';
5import { renderDeployCard, renderDeployments, renderHomeDeployLine } from './deploymentView.mjs';
6import { EMBLEM } from './branding.mjs';
7import { PLUGIN_VERSION } from './release.mjs';
8
9const VIEW_NOUN = { Code: 'your repositories', Projects: 'its projects', Agents: 'its agents', Tools: 'its MCP tools' };
10
11export function signedInState(state) {
12 const auth = state.results['devtools.auth-status'];
13 const session = record(auth?.data);
14 return { auth, session, signedIn: auth?.status === 'success' && session.authenticated === true,
15 signedOut: auth?.data?.authenticated === false || auth?.error?.code === 'NOT_AUTHENTICATED' };
16}
17
18/** Drawn when the host refused a tree or startup failed: nothing is replayed. */
19export function renderRecovery(ui, state, actions) {
20 return ui.Box({ flexDirection: 'column', gap: 1, padding: 1, children: [
21 ui.Text({ weight: 'semibold', children: ['MainSequence could not display this screen.'] }),
22 ui.Text({ dimColor: true, children: ['Your running operations have not been restarted.'] }),
23 ui.Text({ dimColor: true, size: 'caption', children: [clean([state.paneFault?.phase, state.paneFault?.element].filter(Boolean).join(' · '))] }),
24 ui.Button({ key: 'retry-pane', label: 'Retry display', variant: 'primary', onPress: actions.retryPane }),
25 ] });
26}
27
28/** Host-themed Desktop primitives: a status header, one row of sections, and list-to-detail views. */
29export function renderPane(ui, state, actions, options = {}) {
30 const p = primitives(ui, state, actions);
31 const { Box, Code, Svg, text, muted, caption, heading, title, status, column, inline, buttons, line, card, button, toggle, input, select, row, badge, result, empty } = p;
32 const dev = (action, extra) => () => actions.dev(action, extra);
33 const project = (action, extra) => () => actions.project(action, extra);
34 const mut = (label, callback, detail = label) => () => actions.confirm(label, detail, callback);
35 const f = state.fields;
36 // Controls wait for this session's own operations; counts and progress cover every session's.
37 const busy = (scope, action) => state.operations.some(op => op.status === 'running' && op.label === `${scope} · ${action}`);
38 const operations = allOperations(state);
39 const running = operations.filter(op => op.status === 'running').length;
40 const runningSetups = operations.filter(op => op.setupTarget && op.status === 'running');
41 const { auth, session, signedIn, signedOut } = signedInState(state);
42 const environments = rows(state.results['devtools.mcp-environments']?.data);
43 const environment = environments.find(r => r.uid === f.mcpEnvironment);
44
45 // Header: account, environment, running operations and settings on every view.
46 const account = state.initializing ? muted('Checking…')
47 : signedIn ? status('success', session.username || 'Signed in')
48 : auth?.status === 'failure' ? status('error', 'Connection needs attention')
49 : button('login', busy('devtools', 'login') ? 'Opening sign-in…' : 'Sign in', dev('login'), { primary: true, available: !busy('devtools', 'login') });
50 const environmentControl = state.environmentsLoading ? muted('Loading environments…')
51 : environments.length ? select('mcp-environment', '', environments.map(r => ({ value: r.uid, label: clean(r.name) })), f.mcpEnvironment, actions.selectEnvironment, 'Choose environment')
52 : signedIn || signedOut === false ? button('mcp-environments', 'Choose environment', actions.refreshEnvironments, { plain: true, dim: true }) : null;
53 const chips = inline([account, environmentControl,
54 button('tab-Activity', running ? `${running} running` : 'Activity', () => actions.tab('Activity'), { plain: true, dim: !running && state.tab !== 'Activity' }),
55 button('tab-Settings', 'Settings', () => actions.tab('Settings'), { plain: true, dim: state.tab !== 'Settings' }),
56 ], { flexWrap: 'wrap', justifyContent: 'flex-end' });
57 // The running version, always, and where it stands against the published catalog.
58 const plugin = record(state.plugin), version = PLUGIN_VERSION || plugin.running;
59 const release = state.pluginUpdating ? badge(`Updating to ${plugin.latest}…`, 'warning')
60 : plugin.status === 'update_available' ? button('plugin-update', `Update to ${plugin.latest}`, actions.updatePlugin, { primary: true })
61 : plugin.status === 'restart_required' ? badge(`${plugin.installed} installed · start a new Code session`, 'warning')
62 : plugin.status === 'current' ? badge('● Up to date', 'success')
63 : state.pluginCheck ? badge('Checking for updates…')
64 : state.ready && state.booted ? button('plugin-check', 'Check for updates', actions.checkPlugin, { plain: true, dim: true }) : null;
65 const brand = inline([Svg({ source: EMBLEM, alt: 'MainSequence emblem', width: 24, height: 24 }), heading('MainSequence'),
66 version && version !== 'unknown' && badge(`v${version}`), release], { flexWrap: 'wrap' });
67 const pluginHelp = state.pluginHelp && card('Update MainSequence', [status('warning', state.pluginHelp),
68 text('1. Open Customize → Plugins and refresh the mainsequence-desktop marketplace.', { wrap: 'wrap' }),
69 text('2. Update MainSequence.'), text('3. Start a new Code session.'),
70 buttons([button('plugin-help-dismiss', 'Dismiss', actions.closePluginHelp, { plain: true })])]);
71 const navigation = Box({ key: 'section-navigation', flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: TABS.map(tab =>
72 button(`tab-${tab}`, tab, () => actions.tab(tab), tab === state.tab ? { primary: true } : { plain: true, dim: true })) });
73 const blocker = (() => {
74 if (!state.ready) return null;
75 if (auth?.status === 'failure') return status('error', auth.summary);
76 if (signedOut && VIEW_NOUN[state.tab]) return status('warning', `Sign in to MainSequence to see ${VIEW_NOUN[state.tab]}.`);
77 if (['Projects', 'Agents', 'Tools'].includes(state.tab) && !f.mcpEnvironment && !state.environmentsLoading) {
78 return inline([status('warning', `Choose an organization environment to see ${VIEW_NOUN[state.tab]}.`),
79 !environments.length && !signedOut && button('blocker-environments', 'Load environments', actions.refreshEnvironments, { plain: true })]);
80 }
81 if (state.tab === 'Code' && !f.baseDir) return inline([status('warning', 'Choose a repositories folder before setting up repositories.'), button('blocker-folder', 'Choose folder', project('pick-folder', { destination: 'baseDir' }), { plain: true })]);
82 return null;
83 })();
84 const header = column([
85 options.placement === 'dock' ? inline([brand, Box({ flexGrow: 1 }), chips], { justifyContent: 'space-between', flexWrap: 'wrap' }) : column([brand, chips]),
86 pluginHelp, navigation, blocker,
87 state.tab !== 'Activity' && runningSetups.some(op => !(state.tab === 'Code' && state.codeDetail && !state.codeDetail.root && op.setupTarget === setupKey(f)))
88 ? column(runningSetups.filter(op => !(state.tab === 'Code' && state.codeDetail && !state.codeDetail.root && op.setupTarget === setupKey(f))).map(op =>
89 inline([status('warning', `Setting up ${op.context}: ${op.progress}`), button(`show-setup-activity-${op.id}`, 'View progress', () => actions.tab('Activity'), { plain: true, dim: true })], { flexWrap: 'wrap' })), { gap: 0 }) : null,
90 state.workspaceAttachment && inline([status(state.workspaceAttachment.error ? 'error' : state.workspaceAttachment.running ? 'warning' : 'success', state.workspaceAttachment.message),
91 state.workspaceAttachment.error && button('workspace-attach-retry', 'Retry adding repositories', actions.attachWorkspace, { plain: true })], { flexWrap: 'wrap' }),
92 state.problem && status('error', state.problem),
93 ]);
94 const page = body => Box({ flexDirection: 'column', gap: 1, padding: 1, children: [header, ...body.filter(Boolean)] });
95 if (!state.booted) return page([muted('Loading MainSequence…')]);
96 if (!state.ready) return page([card('Update required', [text('Update Claude Desktop to use MainSequence in a local Code session.')])]);
97 // A pending confirmation sits above the view it belongs to; the header and navigation stay.
98 const confirmation = state.confirmation && Box({ flexDirection: 'column', gap: 1, paddingX: 1, paddingY: 1, borderStyle: 'round', borderColor: 'warning', children: [
99 title(state.confirmation.title),
100 state.confirmation.detail.split('\n').length > 3 ? Code({ source: clean(state.confirmation.detail), language: 'text', wrap: 'wrap' }) : text(state.confirmation.detail, { wrap: 'wrap' }),
101 buttons([button('confirm-action', 'Confirm', actions.accept, { primary: true }), button('dismiss-confirmation', 'Cancel', actions.dismiss)]),
102 ] });
103 const gated = blocker && (signedOut || (['Projects', 'Agents', 'Tools'].includes(state.tab) && !f.mcpEnvironment));
104 let body = null;
105
106 if (state.tab === 'Home') {
107 const projectStatus = record(state.results['devtools.status']?.data), context = record(projectStatus.project);
108 const sdk = record(state.results['devtools.sdk-status']?.data);
109 const forSession = !f.root || f.root === state.cwd;
110 const sdkLine = sdk.updateAvailable === true ? ['warning', `${sdk.installed || 'SDK'} → ${sdk.latest || 'newer'} available`] : sdk.installed ? ['success', `${sdk.installed} up to date`] : null;
111 const recent = operations.slice(-3).reverse();
112 const steps = [
113 !signedIn && !state.initializing && { key: 'login', label: 'Sign in with your browser', detail: 'Browse projects, agents and tools with your MainSequence account.' },
114 signedIn && !f.mcpEnvironment && { key: 'environment', label: 'Choose an organization environment', detail: 'Projects, agents and tools are scoped to one environment.' },
115 !f.baseDir && { key: 'folder', label: 'Choose a repositories folder', detail: 'Repositories and project workspaces are created there.' },
116 ].filter(Boolean);
117 body = column([
118 steps.length ? card('Next steps', steps.map(step => row(`step-row-${step.key}`, [title(step.label), caption(step.detail)], [
119 step.key === 'login' && button('step-login', 'Sign in', dev('login'), { primary: true, available: !busy('devtools', 'login') }),
120 step.key === 'environment' && (environments.length ? null : button('step-environments', 'Load environments', actions.refreshEnvironments, { primary: true })),
121 step.key === 'folder' && button('step-folder', 'Choose folder', project('pick-folder', { destination: 'baseDir' }), { primary: true }),
122 ]))) : null,
123 card('This session', [
124 row('home-session', [title(basename(state.cwd) || 'Current folder'), caption(state.cwd)], [
125 button('home-open-code', 'Open in Code', () => actions.openCode({ root: state.cwd }), { plain: true })]),
126 forSession && context.branch && line('Branch', context.branch),
127 forSession && sdkLine && line('SDK', sdkLine[1], sdkLine[0]),
128 forSession && renderHomeDeployLine(p, state, actions, deployTarget(state, { root: state.cwd })),
129 forSession && !state.results['devtools.status'] && caption(busy('devtools', 'status') ? 'Checking the repository…' : 'Repository status has not been checked yet.'),
130 !forSession && caption(`Code is targeting ${f.root}.`),
131 buttons([button('home-status', busy('devtools', 'status') ? 'Checking…' : 'Check status', actions.checkHomeStatus, { available: !busy('devtools', 'status') }),
132 forSession && sdk.updateAvailable === true && sdk.updateSupported === true && button('home-update-sdk', 'Update SDK', mut('Update MainSequence SDK', dev('update-sdk')), { primary: true }),
133 signedIn && f.mcpEnvironment && button('home-deployments', 'All deployments', () => actions.openDeployments('home'), { plain: true, dim: true })]),
134 forSession && result('devtools.status'),
135 ]),
136 card('Recent', recent.length ? [
137 ...recent.map(op => row(`recent-${op.id}`, [title(op.title || op.label), caption(op.progress)], [badge(OPERATION_STATUS[op.status] || op.status, OPERATION_KIND[op.status])])),
138 button('home-activity', 'See all activity', () => actions.tab('Activity'), { plain: true, dim: true }),
139 ] : [muted('Nothing has run yet.'), buttons([button('home-projects', 'Set up a project', () => actions.tab('Projects'), { plain: true }), button('home-agents', 'Talk to an agent', () => actions.tab('Agents'), { plain: true })])]),
140 ]);
141 }
142
143 if (state.tab === 'Code') body = renderCode(p, state, actions, { dev, project, mut, busy, gated, environment });
144
145 if (state.tab === 'Projects') body = gated ? null : renderProjects(ui, state, actions);
146 if (state.tab === 'Agents') body = gated ? null : renderAgents(ui, state, actions);
147 if (state.tab === 'Tools') body = gated ? null : renderTools(p, state, actions, { dev, mut, busy, environment });
148
149 if (state.tab === 'Activity') body = column([
150 inline([heading('Activity'), operations.length ? caption(['running', 'success', 'failure', 'cancelled', 'stale'].map(kind => {
151 const count = operations.filter(op => op.status === kind).length; return count ? `${count} ${OPERATION_STATUS[kind].toLowerCase()}` : '';
152 }).filter(Boolean).join(' · ')) : null], { justifyContent: 'space-between' }),
153 ...(operations.length ? operations.slice().reverse().map(op => column([
154 row(`activity-row-${op.id}`, [inline([status(OPERATION_KIND[op.status], OPERATION_STATUS[op.status] || op.status), title(op.title || op.label)]),
155 (op.context || op.remote) && caption([op.context, op.remote && `Session ${op.session || 'elsewhere'}`].filter(Boolean).join(' · ')),
156 text(op.progress, { wrap: 'wrap', ...(op.status === 'failure' ? { color: 'error' } : {}) })], [
157 // Only the session that runs an operation can stop it.
158 op.status === 'running' && !op.remote && button(`cancel-${op.id}`, op.cancelled ? 'Cancelling…' : 'Cancel operation', () => actions.cancel(op.id), { plain: true, available: !op.cancelled }),
159 op.result && toggle(`activity-${op.id}`, 'Details')]),
160 op.result?.effects?.message && status('warning', op.result.effects.message),
161 state.expanded[`activity-${op.id}`] && Code({ source: resultText(op.result), language: 'json', wrap: 'wrap' }),
162 ])) : [empty('No activity yet', 'Set up a repository, connect an MCP or talk to an agent. Progress and results appear here.',
163 [button('activity-projects', 'Open Code', () => actions.tab('Code'), { plain: true }), button('activity-mcp', 'Open Tools', () => actions.tab('Tools'), { plain: true })])]),
164 ]);
165
166 if (state.tab === 'Settings') {
167 const diagnostics = record(state.results['projects.runtime-diagnostics']?.data);
168 body = column([
169 card('MainSequence account', [
170 state.initializing ? muted('Checking your saved connection…') : signedIn ? status('success', `Signed in as ${session.username || 'MainSequence account'}`) : auth?.status === 'failure' ? status('error', auth.summary) : muted('Sign in with your browser to use projects, agents and tools.'),
171 signedIn && caption(session.checked ? 'Connection verified' : 'Using your saved MainSequence session'),
172 buttons([!signedIn && button('settings-login', busy('devtools', 'login') ? 'Opening sign-in…' : 'Sign in with browser', dev('login'), { primary: true, available: !busy('devtools', 'login') }),
173 button('auth-status', 'Check connection', dev('auth-status', { check: true })),
174 signedIn && toggle('connection-actions', 'Manage account')]),
175 state.expanded['connection-actions'] && buttons([button('refresh-token', 'Renew session', dev('refresh-token')), button('logout', 'Sign out', mut('Sign out of MainSequence', dev('logout'), 'Remove the saved MainSequence login for this backend.'), { dim: true })]),
176 ...['login', 'refresh-token', 'logout'].map(action => state.results[`devtools.${action}`]).filter(value => value?.status === 'failure').map(value => status('error', value.summary)),
177 ]),
178 card('Workspace', [
179 input('baseDir', 'Code repositories folder'),
180 buttons([button('pick-base', 'Choose folder', project('pick-folder', { destination: 'baseDir' }), { plain: true })]),
181 input('backend', 'Backend URL'),
182 input('commandCenterUrl', 'Command Center URL (optional)'),
183 caption('Changes apply to every MainSequence session.'),
184 state.preferencesLoading && muted('Loading workspace settings…'),
185 state.results['projects.workspace-preferences']?.status === 'failure' && status('warning', 'Saved workspace preferences could not be read. Check the displayed values before continuing.'),
186 ]),
187 card('Advanced', [
188 toggle('runtime', 'Runtime & diagnostics'),
189 state.expanded.runtime && column([
190 line('Claude Code', state.version || 'Unknown'),
191 diagnostics.version && line('Plugin', diagnostics.version), diagnostics.nodeVersion && line('Node.js', diagnostics.nodeVersion),
192 diagnostics.bundleHealth && line('Plugin files', Object.values(diagnostics.bundleHealth).every(Boolean) ? 'Ready' : 'Needs attention', Object.values(diagnostics.bundleHealth).every(Boolean) ? 'success' : 'error'),
193 button('diagnostics', 'Run diagnostics', project('runtime-diagnostics')),
194 state.results['projects.runtime-diagnostics']?.status === 'failure' && status('error', state.results['projects.runtime-diagnostics'].summary),
195 toggle('runtime-paths', 'Executable overrides'),
196 state.expanded['runtime-paths'] && input('nodePath', 'Node.js executable', 'node'),
197 state.results['projects.runtime-diagnostics'] && toggle('runtime-details', 'Details'),
198 state.expanded['runtime-details'] && Code({ source: resultText(state.results['projects.runtime-diagnostics']), language: 'json', wrap: 'wrap' }),
199 ]),
200 toggle('local-location', 'Local discovery'),
201 state.expanded['local-location'] && column([input('organization', 'Organization folder'), button('local-list', 'Scan local repositories', project('local-repositories'), { available: !!f.baseDir && !!f.organization, reason: 'Needs a repositories folder and an organization folder.' })]),
202 ]),
203 ]);
204 }
205 return page([confirmation, Box({ key: 'section-content', children: [body].filter(Boolean) })]);
206}
207
208function renderCode(p, state, actions, { dev, project, mut, busy, gated, environment }) {
209 const { Code, text, muted, caption, heading, title, status, column, inline, buttons, line, card, button, toggle, input, select, row, badge, result, empty } = p;
210 const f = state.fields;
211 // Deployments are remote state drawn in place of the Code body: the environment-wide list or one run.
212 if (state.deployView) return renderDeployments(p, state, actions, environment);
213 const listed = state.results['devtools.repositories'];
214 const remote = rows(listed?.data);
215 const locals = rows(state.results['projects.local-repositories']?.data);
216 const localFor = uid => locals.find(r => String(r.repositoryUid) === String(uid));
217 const typeLabel = r => r.code_repository_type === 'vite_react' ? 'Vite / React' : r.primary_language || r.code_repository_type || 'Repository';
218 const detail = state.codeDetail;
219 if (detail) {
220 const selected = remote.find(r => String(r.uid) === String(detail.repositoryUid));
221 const local = detail.root;
222 const back = button('code-back', '← Code', actions.closeCode, { plain: true, dim: true });
223 if (!local) {
224 // Remote-only repository: choose a branch, then set it up under the repositories folder.
225 const branches = rows(selected?.branches).map(r => ({ value: r.repository_branch, label: r.repository_branch }));
226 const name = selected?.code_repository_name || selected?.name || detail.repositoryUid;
227 const setup = allOperations(state).findLast(op => op.setupTarget === setupKey(f));
228 const settingUp = setup?.status === 'running';
229 return column([back, heading(name), caption(`${selected ? typeLabel(selected) : 'Repository'} · not on this computer`),
230 card('Set up', [
231 select('repository-branch', 'Branch', branches, f.branch, value => actions.field('branch', value)),
232 !branches.length && muted('This repository has no branches available for setup yet.'),
233 line('Destination', f.baseDir || 'Choose a repositories folder in Settings'),
234 toggle('setup-install', state.expanded['setup-install'] ? 'Install dependencies after setup' : 'Install dependencies after setup (off)'),
235 buttons([button('setup', settingUp ? 'Setting up…' : 'Set up', () => actions.setup(!!state.expanded['setup-install']),
236 { primary: true, available: !!f.branch && !!f.baseDir && !settingUp, reason: settingUp ? '' : !f.baseDir ? 'Choose a repositories folder first.' : !f.branch ? 'Choose a branch first.' : '' })]),
237 setup && column([
238 status(settingUp ? 'warning' : setup.status === 'success' ? 'success' : setup.status === 'cancelled' ? 'info' : 'error',
239 settingUp ? 'Setting up locally…' : setup.status === 'success' ? 'Local setup completed' : setup.status === 'cancelled' ? 'Setup cancelled' : 'Setup failed'),
240 caption(setup.context), text(setup.progress, { wrap: 'wrap' }),
241 settingUp && !setup.remote && button('cancel-setup', setup.cancelled ? 'Cancelling…' : 'Cancel setup', () => actions.cancel(setup.id), { plain: true, available: !setup.cancelled }),
242 setup.result?.effects && status('warning', setup.result.effects.message),
243 setup.status === 'failure' && caption('Review the error and any partial changes before starting setup again.'),
244 setup.result?.data?.root && button('setup-open', 'Open local repository', () => actions.openCode({ root: setup.result.data.root, repositoryUid: detail.repositoryUid }), { primary: true }),
245 button('setup-activity', 'View activity', () => actions.tab('Activity'), { plain: true, dim: true }),
246 ], { gap: 0 }),
247 ])]);
248 }
249 const capabilities = record(state.results['devtools.status']?.data).capabilities;
250 const context = record(record(state.results['devtools.status']?.data).project);
251 const available = action => !Array.isArray(capabilities) || !capabilities.some(c => c.action === action && c.state !== 'available');
252 const reason = action => Array.isArray(capabilities) ? capabilities.find(c => c.action === action && c.state !== 'available')?.reason || '' : '';
253 const sdk = record(state.results['devtools.sdk-status']?.data);
254 const sdkUpdate = available('update-sdk') && sdk.updateSupported === true && sdk.updateAvailable === true;
255 const sdkLine = sdk.updateAvailable === true ? ['warning', `${sdk.installed || 'installed'} → ${sdk.latest || 'newer'} available`] : sdk.installed ? ['success', `${sdk.installed} up to date`] : sdk.hint ? ['info', sdk.hint] : null;
256 return column([
257 inline([back, local === state.cwd && badge('This session')], { justifyContent: 'space-between' }),
258 heading(basename(local)), caption(local),
259 card('Status', [
260 input('root', 'Folder'), buttons([button('pick-root', 'Choose folder', project('pick-folder', { destination: 'root' }), { plain: true, dim: true })]),
261 select('project-type', 'Repository type', [{ value: '', label: 'Detect automatically' }, { value: 'python', label: 'Python' }, { value: 'vite_react', label: 'Vite / React' }], f.projectType, value => actions.field('projectType', value)),
262 context.branch && line('Branch', `${context.branch}${context.commit ? ` · ${String(context.commit).slice(0, 7)}` : ''}`),
263 context.projectType && line('Type', context.projectType === 'vite_react' ? 'Vite / React' : context.projectType),
264 sdkLine && line('SDK', sdkLine[1], sdkLine[0]),
265 !state.results['devtools.status'] && caption(busy('devtools', 'status') ? 'Checking the repository…' : 'Status has not been checked yet.'),
266 buttons([button('project-status', busy('devtools', 'status') ? 'Checking…' : 'Check status', dev('status'), { available: !busy('devtools', 'status') }),
267 button('sdk-status', 'Check SDK', dev('sdk-status'), { plain: true }), button('project-resolve', 'Detect repository', dev('resolve'), { plain: true, dim: true })]),
268 result('devtools.status'), result('devtools.sdk-status'),
269 ]),
270 renderDeployCard(p, state, actions, deployTarget(state, detail)),
271 card('Dependencies', [
272 buttons([button('sync', 'Sync dependencies', mut('Sync dependencies', dev('sync')), { primary: true, available: available('sync'), reason: reason('sync') }),
273 button('install', 'Install dependencies', mut('Install dependencies', dev('install')), { available: available('install'), reason: reason('install') }),
274 sdkUpdate && button('update-sdk-preview', 'Preview SDK update', dev('update-sdk', { dryRun: true }), { plain: true }),
275 sdkUpdate && button('update-sdk', 'Update SDK', mut('Update MainSequence SDK', dev('update-sdk'))),
276 ]),
277 !sdkUpdate && caption(sdk.hint || 'Check SDK to see whether an automatic update is available.'),
278 ]),
279 card('Instructions and skills', [
280 buttons([button('update-agents', 'Update instructions', mut('Update agent instructions', dev('update-agents')), { available: available('update-agents'), reason: reason('update-agents') }),
281 button('install-skills', 'Install skills', mut('Install repository skills', dev('install-skills')), { available: available('install-skills'), reason: reason('install-skills') }),
282 toggle('code-skills-more', 'More')]),
283 state.expanded['code-skills-more'] && buttons([
284 button('skills-preview', 'Preview skill installation', dev('install-skills', { dryRun: true }), { plain: true, available: available('install-skills') }),
285 button('skills-sync', 'Sync skills', mut('Sync repository skills', dev('skills-sync')), { plain: true, available: available('skills-sync') }),
286 button('env-cleanup', 'Remove old credentials from .env', mut('Remove old credentials', dev('env-cleanup'), 'Remove migrated MainSequence credentials from the repository environment file.'), { plain: true, available: available('env-cleanup') })]),
287 ]),
288 card('More', [
289 buttons([button('open-folder', 'Open folder', dev('open-folder'), { plain: true, available: available('open-folder') }),
290 button('signed-terminal', 'Terminal', mut('Open signed terminal', dev('signed-terminal'), 'Open a terminal with the MainSequence repository environment.'), { plain: true, available: available('signed-terminal'), reason: reason('signed-terminal') }),
291 toggle('code-container', 'Containers'),
292 state.selectedLocal === local && button('delete-local-preview', 'Delete local copy…', project('delete-local-preview'), { plain: true, dim: true })]),
293 state.expanded['code-container'] && column([
294 input('image', 'Container image', 'mainsequence-project:latest'), input('platform', 'Docker platform', 'linux/arm64'),
295 buttons([button('docker-preview', 'Review Docker build', project('docker-preview')),
296 button('devcontainer-preview', 'Review container configuration', project('devcontainer-preview')),
297 button('open-container', 'Open container', project('open-container'), { plain: true })]),
298 result('projects.open-container'),
299 ]),
300 ]),
301 ]);
302 }
303 // List: the session folder, every remote repository with its local state, and local copies.
304 const search = f.codeSearch.trim().toLowerCase();
305 const matches = name => !search || String(name || '').toLowerCase().includes(search);
306 const items = [];
307 const seen = new Set();
308 for (const r of remote) {
309 const local = localFor(r.uid);
310 if (local) seen.add(local.path);
311 const name = r.code_repository_name || r.name || r.uid;
312 if (!matches(name)) continue;
313 if (state.codeFilter === 'local' && !local) continue;
314 items.push({ key: `repository-${r.uid}`, name, meta: `${typeLabel(r)}${rows(r.branches).length ? ` · ${rows(r.branches).length} ${rows(r.branches).length === 1 ? 'branch' : 'branches'}` : ''}`, local: local?.path, uid: r.uid });
315 }
316 for (const local of locals) {
317 if (seen.has(local.path) || !matches(local.name) || local.path === state.cwd) continue;
318 items.push({ key: `local-${local.path}`, name: local.name, meta: local.isGitRepository === false ? 'Folder' : 'Local repository', local: local.path, uid: local.repositoryUid || '' });
319 }
320 return column([
321 inline([p.grow(p.Input({ key: 'field-codeSearch', value: f.codeSearch, placeholder: 'Search repositories', onInput: value => actions.field('codeSearch', value), onSubmit: value => actions.field('codeSearch', value) })),
322 button('code-filter-all', 'All', () => actions.codeFilter('all'), state.codeFilter === 'all' ? {} : { plain: true, dim: true }),
323 button('code-filter-local', 'Local', () => actions.codeFilter('local'), state.codeFilter === 'local' ? {} : { plain: true, dim: true }),
324 button('list-repositories', state.repositoriesLoading ? 'Loading…' : 'Refresh', actions.refreshRepositories, { plain: true, dim: true, available: !state.repositoriesLoading })], { flexWrap: 'wrap' }),
325 !search && state.codeFilter === 'all' && row('code-session', [inline([title(basename(state.cwd) || 'Current folder'), badge('This session')]), caption(state.cwd)],
326 [button('code-open-session', 'Open', () => actions.openCode({ root: state.cwd }), { plain: true })]),
327 gated ? null : listed?.status === 'failure' && status('error', listed.summary),
328 gated ? null : state.results['projects.local-repositories']?.status === 'failure' && status('warning', state.results['projects.local-repositories'].summary),
329 ...items.map(item => row(item.key, [inline([title(item.name), item.local && badge('Local', 'success')]), caption(item.meta)], [
330 item.local ? button(`open-${item.key}`, 'Open', () => actions.openCode({ root: item.local, repositoryUid: item.uid }), { plain: true })
331 : button(`setup-${item.key}`, 'Set up', () => actions.openCode({ repositoryUid: item.uid }), { primary: true })])),
332 !gated && listed?.status === 'success' && !items.length && empty(search ? 'No matching repositories' : state.codeFilter === 'local' ? 'No local copies yet' : 'No repositories', search ? 'Try another search.' : 'Repositories available to your account appear here.'),
333 !gated && !listed && !state.repositoriesLoading && empty('Repositories', 'Load the repositories available to your MainSequence account.', [button('code-load', 'Load repositories', actions.refreshRepositories, { primary: true })]),
334 ]);
335}
336
337function renderTools(p, state, actions, { dev, mut, busy, environment }) {
338 const { text, muted, caption, heading, title, status, column, inline, buttons, line, card, button, toggle, input, select, row, badge, result, empty } = p;
339 const f = state.fields;
340 const matches = r => r.mcp_connection?.organization_environment_uid === f.mcpEnvironment;
341 const connected = rows(state.results['devtools.mcp-list']?.data).filter(matches);
342 const discovered = rows(state.results['devtools.mcp-discover']?.data).filter(matches);
343 const registered = Object.values(state.mcpTools);
344 const toolCount = release => registered.filter(entry => entry.releaseUid === release).length;
345 const connection = connected.find(r => r.resource_release_uid === state.selectedMcp);
346 if (state.toolsView === 'detail' && connection) {
347 const tools = rows(record(state.results['devtools.mcp-tools']?.data).tools);
348 return column([
349 button('tools-back', '← Tools', actions.closeTool, { plain: true, dim: true }),
350 heading(mcpDisplayName(connection)), caption(`${environment?.name || 'Environment'} · ${toolCount(connection.resource_release_uid)} tools registered for Claude`),
351 buttons([button('mcp-tools', busy('devtools', 'mcp-tools') ? 'Loading tools…' : 'Reload tools', dev('mcp-tools'), { plain: true, available: !busy('devtools', 'mcp-tools') }),
352 button('mcp-remove', 'Disconnect', mut('Disconnect MCP', dev('mcp-remove'), `Disconnect ${mcpDisplayName(connection)} from ${environment?.name || 'this environment'}.`), { plain: true, dim: true })]),
353 state.results['devtools.mcp-tools']?.status === 'failure' && status('error', state.results['devtools.mcp-tools'].summary),
354 ...tools.map(tool => row(`tool-${tool.name}`, [title(tool.name), tool.description && caption(tool.description)], [])),
355 state.results['devtools.mcp-tools']?.status === 'success' && !tools.length && muted('This MCP exposes no tools.'),
356 caption('Tools are available to Claude from the next prompt; each call asks for approval.'),
357 toggle('tools-advanced', 'Advanced'),
358 state.expanded['tools-advanced'] && card('Call a tool directly', [
359 tools.length && select('mcp-tool', 'Tool', tools.map(tool => ({ value: tool.name, label: clean(tool.name) })), f.mcpTool, value => actions.field('mcpTool', value)),
360 input('mcpTool', 'Tool name'), input('mcpArguments', 'Arguments (JSON)'),
361 buttons([button('mcp-call', 'Call tool', mut('Call MCP tool', dev('mcp-call'), `Call ${f.mcpTool || 'the selected tool'} with the JSON arguments shown. The operation may change remote state.`), { available: !!f.mcpTool, reason: 'Choose a tool first.' }),
362 button('mcp-register', 'Re-register tools', actions.refreshTools, { plain: true, dim: true })]),
363 caption('Re-register when Claude cannot see tools that this MCP exposes.'),
364 result('devtools.mcp-call'), result('mcp.registration'),
365 ]),
366 ]);
367 }
368 if (state.toolsView === 'add') {
369 const page = state.results['devtools.mcp-discover']?.data;
370 const total = page?.count || 0;
371 const connectedIds = new Set(connected.map(r => r.resource_release_uid));
372 return column([
373 button('tools-back', '← Tools', actions.closeTool, { plain: true, dim: true }),
374 heading('Add MCP'), caption(`Search the MCPs released in ${environment?.name || 'this environment'}.`),
375 inline([p.grow(p.Input({ key: 'field-mcpSearch', value: f.mcpSearch, placeholder: 'Search MCPs', autoFocus: true, onInput: value => actions.field('mcpSearch', value), onSubmit: value => { actions.field('mcpSearch', value); actions.dev('mcp-discover', { offset: 0 }); } })),
376 button('mcp-discover', busy('devtools', 'mcp-discover') ? 'Searching…' : 'Search', dev('mcp-discover', { offset: 0 }), { primary: true, available: !busy('devtools', 'mcp-discover') })]),
377 ...discovered.map(r => row(`mcp-discovered-${r.resource_release_uid || r.uid}`, [title(mcpDisplayName(r)), caption(r.mcp_connection?.organization_environment_name || environment?.name || '')], [
378 connectedIds.has(r.resource_release_uid) ? badge('Connected', 'success')
379 : button(`mcp-add-${r.resource_release_uid}`, 'Connect', () => actions.connectMcp(r.resource_release_uid, mcpDisplayName(r)), { available: !!r.resource_release_uid })])),
380 state.results['devtools.mcp-discover']?.status === 'failure' && status('error', state.results['devtools.mcp-discover'].summary),
381 state.results['devtools.mcp-discover']?.status === 'success' && !discovered.length && muted('No MCPs found in this environment.'),
382 state.mcpOffset + discovered.length < total && button('mcp-next', 'Load more', dev('mcp-discover', { offset: state.mcpOffset + 25 }), { plain: true }),
383 result('devtools.mcp-add'),
384 ]);
385 }
386 return column([
387 inline([heading(`Connected in ${environment?.name || 'environment'}`), button('tools-add', 'Add MCP', actions.openAddMcp, { primary: true })], { justifyContent: 'space-between' }),
388 ...connected.map(r => row(`mcp-connected-${r.resource_release_uid}`, [button(`mcp-open-${r.resource_release_uid}`, mcpDisplayName(r), () => actions.openTool(r.resource_release_uid), { plain: true }),
389 inline([status('success', 'Connected'), caption(`${toolCount(r.resource_release_uid)} tools`)])], [
390 button(`mcp-remove-${r.resource_release_uid}`, 'Disconnect', () => actions.disconnectMcp(r.resource_release_uid, mcpDisplayName(r)), { plain: true, dim: true })])),
391 !connected.length && empty('No MCPs connected', 'Connect an MCP release to give Claude its tools in this environment.'),
392 state.results['devtools.mcp-list']?.status === 'failure' && status('error', state.results['devtools.mcp-list'].summary),
393 (state.results['mcp.registration']?.status === 'failure' || rows(state.results['mcp.registration']?.data?.unavailable).length > 0) && status('warning', state.results['mcp.registration'].summary),
394 inline([caption('Tools become available to Claude on the next prompt.'), button('mcp-list', 'Refresh', dev('mcp-list'), { plain: true, dim: true })], { justifyContent: 'space-between' }),
395 result('devtools.mcp-remove'),
396 ]);
397}
398hooks/branding.mjs 3 lines1// Generated by scripts/sync-branding.cjs from assets/mainsequence-emblem.png.
2export const EMBLEM = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 400\"><image width=\"400\" height=\"400\" href=\"data:image/png;base64,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\"/></svg>";
3hooks/workspace.mjs 10 lines1// Claude's folder link accepts one folder. Explicit /add-dir commands register
2// each sibling checkout in the new session, including its per-repository context.
3export function workspaceBinding(workspace) {
4 if (!workspace?.root || !workspace.backend || !workspace.projectUid || !workspace.environmentUid
5 || !Array.isArray(workspace.repositories) || !workspace.repositories.length) throw new Error('The project has no prepared repository memberships. Run setup again.');
6 return { root: workspace.root, backend: workspace.backend, projectUid: workspace.projectUid, environmentUid: workspace.environmentUid,
7 repositories: workspace.repositories.map(row => ({ repositoryUid: row.repositoryUid, branchUid: row.branchUid, branch: row.branch, path: row.path })) };
8}
9
10hooks/agentIcon.mjs 38 lines1/** Same priority as Command Center's resolveAgentIconPose. */
2export function resolveAgentIconPose(icon, activity) {
3 if (!activity) return 'idle';
4 const declared = pose => pose && Object.hasOwn(icon?.images || {}, pose) && icon.images[pose] ? pose : null;
5 if (activity.tool?.phase === 'running') return declared(icon?.toolPoses?.[activity.tool.application]) || declared('working') || 'idle';
6 if (activity.tool?.phase === 'failed' || activity.failed) return declared('error') || 'idle';
7 return activity.thinking ? declared('thinking') || 'idle' : 'idle';
8}
9
10export function agentIconActivity(binding) {
11 return { thinking: binding?.iconPhase !== 'approval' && !!(binding?.busy || binding?.status === 'working'),
12 failed: !binding?.busy && (binding?.status === 'failed' || ['failed', 'rejected'].includes(binding?.task?.status)), tool: null };
13}
14
15export function renderAgentIcon(ui, state, agentUid, { size = 20, animate = true, activity = null } = {}) {
16 const icon = state.agentIcons?.[agentUid], pose = resolveAgentIconPose(icon, activity);
17 const image = icon?.images?.[pose] || icon?.images?.idle;
18 if (!image || typeof image.source !== 'string' || typeof image.stillSource !== 'string'
19 || image.source.length > 131072 || image.stillSource.length > 131072) {
20 return null;
21 }
22 return ui.Box({ key: `agent-icon-${agentUid}-${size}`, flexShrink: 0, children: [ui.Svg({ source: animate ? image.source : image.stillSource, alt: 'Agent icon', width: size, height: size,
23 // Desktop renders this as a native image. The SVG carries its own media queries;
24 // no script, remote URL or frame timer crosses the widget boundary.
25 isInteractive: false })] });
26}
27
28export function renderAgentActivity(ui, state, binding) {
29 if (!binding) return null;
30 const activity = agentIconActivity(binding);
31 const label = binding.uncertain && !binding.busy ? 'Delivery needs review'
32 : binding.iconPhase === 'approval' ? 'Review agent request' : binding.busy ? 'Waiting for agent…' : binding.status === 'working' ? 'Agent task in progress'
33 : activity.failed ? 'Conversation needs attention' : binding.status === 'ready' ? 'Reply received · your next message goes here too' : 'Agent ready · your messages go here';
34 return ui.Box({ key: 'agent-chat-activity', flexDirection: 'row', flexWrap: 'wrap', flexShrink: 1, minWidth: 0, alignItems: 'center', gap: 1,
35 children: [renderAgentIcon(ui, state, binding.agent.uid, { size: 24, activity }),
36 ui.Text({ children: [binding.agent.name] }), ui.Text({ dimColor: true, children: [label] })].filter(Boolean) });
37}
38hooks/deploymentView.mjs 234 lines1import { clean } from './model.mjs';
2import { STATUS } from './primitives.mjs';
3
4/** Command Center's wording for a run that answers 404: it never says whether the record exists. */
5export const HIDDEN_DEPLOYMENT = "You can't see this deployment. A deployment is shown to the people who can see its release or Job.";
6const ACTIVE = new Set(['pending', 'queued', 'running']);
7/** One state vocabulary for runs, workflow steps and deployment stages, mapped onto the host's status colours. */
8const STEP_KIND = { succeeded: 'success', completed: 'success', active: 'success', running: 'warning',
9 failed: 'error', error: 'error', errored: 'error', cancelled: 'info', canceled: 'info', pending: 'info', queued: 'info',
10 blocked: 'info', skipped: 'info', superseded: 'info', stopped: 'info' };
11const COLOUR = { success: '#2f9e5b', warning: '#d99a1b', error: '#d64545', info: '#8a8a8a' };
12
13export const stepKind = state => STEP_KIND[String(state || '').toLowerCase()] || 'info';
14export const stateLabel = state => { const value = clean(state || 'unknown').replace(/_/g, ' '); return value.charAt(0).toUpperCase() + value.slice(1); };
15export const kindLabel = run => run?.kind === 'workflow_run' ? 'Workflow run' : 'Deployment';
16export const isActiveRun = run => !!run && (ACTIVE.has(String(run.state)) || (run.deploymentRuns || []).some(owned => ACTIVE.has(String(owned.state))));
17export const runTitle = run => run.kind === 'workflow_run' ? `${run.repositoryName || 'Workflow'} · ${run.workflowPath ? run.workflowPath.split('/').pop() : 'workflow'}` : run.targetLabel || run.repositoryName || 'Deployment';
18export const stepLabel = step => step.key || step.name || step.id || 'step';
19const parse = value => { const time = value ? Date.parse(value) : NaN; return Number.isFinite(time) ? time : null; };
20const two = value => String(value).padStart(2, '0');
21
22export function formatDuration(ms) {
23 if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return '';
24 const seconds = Math.floor(ms / 1000), minutes = Math.floor(seconds / 60), hours = Math.floor(minutes / 60);
25 if (hours) return `${hours}h ${minutes % 60}m`;
26 if (minutes) return `${minutes}m ${seconds % 60}s`;
27 return seconds ? `${seconds}s` : '<1s';
28}
29export function formatWhen(iso) {
30 const time = parse(iso);
31 if (time === null) return '';
32 const date = new Date(time);
33 return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`;
34}
35/** `HH:MM:SS.mmm` for a log line; entries without a timestamp keep the column aligned. */
36export function logTimestamp(iso) {
37 const time = parse(iso);
38 if (time === null) return ' ';
39 const date = new Date(time);
40 return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, '0')}`;
41}
42export const failedStep = run => (run?.steps || []).find(step => stepKind(step.state) === 'error') || null;
43/** A workflow run opens its first failed step, else its deploy step, else the first; a deployment its first failed, else running, else first stage. */
44export function defaultStep(run) {
45 const steps = run?.steps || [];
46 return failedStep(run) || (run?.kind === 'workflow_run' ? steps.find(step => step.operation === 'deploy') : steps.find(step => stepKind(step.state) === 'warning')) || steps[0] || null;
47}
48/** Where a step's logs come from: a deployment run (optionally one stage), or a reason there are none. */
49export function logSource(run, step) {
50 if (!run || !step) return { reason: 'Choose a step to see its logs.' };
51 if (run.kind === 'deployment_run') return { runUid: run.uid, stepUid: step.uid || null };
52 if (step.deploymentRunUid) return (run.deploymentRuns || []).some(owned => owned.uid === step.deploymentRunUid) ? { runUid: step.deploymentRunUid, stepUid: null } : { reason: HIDDEN_DEPLOYMENT };
53 if (step.operation === 'run_job') return { reason: 'Job run logs are shown in Command Center.' };
54 if (['pending', 'blocked', 'skipped', 'superseded'].includes(String(step.state))) return { reason: `This step did not run${step.reason ? `: ${step.reason.replace(/_/g, ' ')}` : ''}.` };
55 return { reason: 'This step has no deployment logs.' };
56}
57export function logText(entries, limit = 90000) {
58 const lines = entries.slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(entry => `${logTimestamp(entry.timestamp)} ${entry.text}`);
59 let text = lines.join('\n');
60 if (text.length > limit) text = `… earlier lines omitted\n${text.slice(text.length - limit)}`;
61 return text;
62}
63
64/** Step bars on the run's clock: small markup, fixed colours by state, labels in the host's text colour. */
65export function timelineSvg(run, nowIso) {
66 const steps = (run?.steps || []).slice(0, 40);
67 const now = parse(nowIso) ?? Date.now();
68 const starts = steps.map(step => parse(step.startedAt)).filter(value => value !== null);
69 const t0 = parse(run.startedAt) ?? parse(run.createdAt) ?? (starts.length ? Math.min(...starts) : now);
70 const ends = steps.map(step => parse(step.finishedAt) ?? (parse(step.startedAt) !== null && stepKind(step.state) === 'warning' ? now : parse(step.startedAt))).filter(value => value !== null);
71 const t1 = Math.max(parse(run.finishedAt) ?? (isActiveRun(run) ? now : t0), ...ends, t0 + 1000);
72 const span = Math.max(1, t1 - t0);
73 const W = 360, L = 112, R = 10, ROW = 14, PAD = 4, H = PAD * 2 + ROW * Math.max(1, steps.length);
74 const x = time => L + Math.max(0, Math.min(1, (time - t0) / span)) * (W - L - R);
75 const escape = value => String(value).replace(/[&<>"]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char]);
76 const parts = steps.map((step, index) => {
77 const y = PAD + index * ROW, colour = COLOUR[stepKind(step.state)];
78 const label = `<text x="0" y="${y + 10}" font-size="10" font-family="system-ui,sans-serif" fill="currentColor">${escape(clean(stepLabel(step)).slice(0, 18))}</text>`;
79 const start = parse(step.startedAt);
80 if (start === null) return `${label}<circle cx="${W - R - 2}" cy="${y + 7}" r="3" fill="none" stroke="${colour}" stroke-width="1.5"/>`;
81 const end = parse(step.finishedAt) ?? (stepKind(step.state) === 'warning' ? now : start);
82 const x0 = x(start), x1 = Math.max(x0 + 2, x(end));
83 return `${label}<rect x="${x0.toFixed(1)}" y="${y + 3}" width="${(x1 - x0).toFixed(1)}" height="8" rx="2" fill="${colour}"/>`;
84 });
85 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><style>:root{color:#202020}@media(prefers-color-scheme:dark){:root{color:#eeeeee}}</style>`
86 + `<line x1="${L}" y1="${PAD}" x2="${L}" y2="${H - PAD}" stroke="#8a8a8a" stroke-opacity="0.4"/>${parts.join('')}</svg>`;
87}
88
89/** Plain text for the model tool: identity, state, timings, steps in plan order and owned runs. */
90export function describeRun(run, nowIso) {
91 const glyph = state => STATUS[stepKind(state)].glyph;
92 const lines = [`${run.repositoryName || 'Unknown repository'} · ${run.branchName || 'unknown branch'} · ${run.commit || 'no commit'}`,
93 `${kindLabel(run)}${run.kind === 'workflow_run' ? ` ${run.workflowPath}` : run.targetLabel ? ` of ${run.targetLabel}` : ''} — ${stateLabel(run.state)}${run.outcome ? ` (${run.outcome})` : ''}${run.reason ? ` · ${run.reason}` : ''}`,
94 `${run.startedAt ? `Started ${formatWhen(run.startedAt)}` : `Created ${formatWhen(run.createdAt)}`}${run.finishedAt ? ` · finished ${formatWhen(run.finishedAt)}` : ''}${run.durationMs !== null && run.durationMs !== undefined ? ` · ${formatDuration(run.durationMs)}${isActiveRun(run) ? ` as of ${formatWhen(nowIso)}` : ''}` : ''}`];
95 if (run.error) lines.push(`Error: ${run.error}`);
96 lines.push('Steps:');
97 for (const step of run.steps || []) {
98 const detail = [step.operation && `${step.operation}${step.resourceKey ? ` · ${step.resourceKey}` : ''}`, step.kind && step.kind].filter(Boolean).join(' ');
99 lines.push(` ${glyph(step.state)} ${stepLabel(step)}${detail ? ` (${detail})` : ''} — ${stateLabel(step.state).toLowerCase()}${step.durationMs !== null && step.durationMs !== undefined ? ` · ${formatDuration(step.durationMs)}` : ''}${step.needs?.length ? ` · needs ${step.needs.join(', ')}` : ''}${step.reason ? ` · ${step.reason.replace(/_/g, ' ')}` : ''}${step.error ? ` · ${step.error}` : ''}`);
100 }
101 if (!(run.steps || []).length) lines.push(' (none yet)');
102 if (run.deploymentRuns?.length) lines.push(`Deployments: ${run.deploymentRuns.map(owned => `${owned.targetLabel || owned.targetType} — ${stateLabel(owned.state).toLowerCase()}`).join(' · ')}`);
103 if (run.kind === 'deployment_run' && run.workflowRun) lines.push(`Started by workflow run ${run.workflowRun.workflowPath} (${run.workflowRun.uid}) — ${stateLabel(run.workflowRun.state).toLowerCase()}`);
104 lines.push(`Run uid: ${run.uid}`);
105 return lines.join('\n');
106}
107
108function glyphs(p, steps, key) {
109 const done = steps.filter(step => stepKind(step.state) === 'success').length;
110 return p.inline([...steps.slice(0, 40).map((step, index) => p.Text({ key: `${key}-${index}`, color: STATUS[stepKind(step.state)].color, children: [STATUS[stepKind(step.state)].glyph] })),
111 p.caption(`${done}/${steps.length} steps`)], { key, flexWrap: 'wrap' });
112}
113const runMeta = run => [run.repositoryName, run.branchName, run.commit].filter(Boolean).join(' · ');
114const runTiming = run => `${run.startedAt ? `Started ${formatWhen(run.startedAt)}` : run.createdAt ? `Created ${formatWhen(run.createdAt)}` : 'Not started'}${run.finishedAt ? ` · finished ${formatWhen(run.finishedAt)}` : ''}${run.durationMs !== null && run.durationMs !== undefined ? ` · ${formatDuration(run.durationMs)}` : ''}`;
115
116/** The Code detail card: the latest run of the checked-out branch, its progress rail and the way into the detail and list. */
117export function renderDeployCard(p, state, actions, target) {
118 const { caption, muted, status, column, inline, buttons, button, badge, card } = p;
119 const environment = state.fields.mcpEnvironment;
120 const latest = target.branchUid ? state.deployLatest[target.branchUid] : null, entry = latest?.entry;
121 return card('Deploys', [
122 !environment && caption('Choose an organization environment in the header to see deployments.'),
123 environment && !target.branchUid && caption(target.repositoryUid ? 'Check status to match this checkout with its MainSequence branch.' : 'This folder is not a known MainSequence repository checkout.'),
124 environment && target.branchUid && latest?.loading && !entry && muted('Loading deployments…'),
125 latest?.error && status('error', latest.error),
126 entry && column([
127 inline([status(stepKind(entry.state), stateLabel(entry.state)), badge(kindLabel(entry)), entry.kind === 'deployment_run' && entry.targetLabel && caption(entry.targetLabel)], { flexWrap: 'wrap' }),
128 caption(runMeta(entry) || runMeta(target) || 'Repository unknown'), caption(runTiming(entry)),
129 entry.steps?.length ? glyphs(p, entry.steps, `deploy-rail-${entry.uid}`) : null,
130 buttons([button(`deploy-open-${entry.uid}`, 'Open run', () => actions.openDeployment(entry.uid, entry.kind, 'code'), { primary: true })]),
131 ], { gap: 0 }),
132 environment && target.branchUid && latest && !latest.loading && !latest.error && !entry && muted('No deployments for this branch yet.'),
133 environment && buttons([button('deploy-all', 'All deployments', () => actions.openDeployments('code'), { plain: true }),
134 target.branchUid && button('deploy-refresh', latest?.loading ? 'Refreshing…' : 'Refresh', actions.refreshDeployments, { plain: true, dim: true, available: !latest?.loading })]),
135 ]);
136}
137
138/** One Home line: the latest run of the session repository's branch, when that branch is known. */
139export function renderHomeDeployLine(p, state, actions, target) {
140 const latest = target.branchUid ? state.deployLatest[target.branchUid] : null, entry = latest?.entry;
141 if (!entry) return null;
142 return p.row('home-deploy', [p.inline([p.muted('Deploy'), p.status(stepKind(entry.state), `${stateLabel(entry.state)} · ${kindLabel(entry)}`)], { flexWrap: 'wrap' }), p.caption(`${runMeta(entry)} · ${runTiming(entry)}`)],
143 [p.button('home-deploy-view', 'View', () => actions.openDeployment(entry.uid, entry.kind, 'home'), { plain: true })]);
144}
145
146/** The environment-wide list and the run detail, drawn in place of the Code section's own body. */
147export function renderDeployments(p, state, actions, environment) {
148 const { Code, Svg, Text, muted, caption, heading, title, status, column, inline, buttons, line, card, button, row, badge, empty } = p;
149 if (state.deployView === 'list') {
150 const list = state.deployList, entries = list?.entries || [];
151 return column([
152 button('deploy-back', '← Code', actions.closeDeployments, { plain: true, dim: true }),
153 inline([heading('Deployments'), list && caption(`${environment?.name || 'Environment'} · ${list.count} ${list.count === 1 ? 'entry' : 'entries'}`)], { justifyContent: 'space-between', flexWrap: 'wrap' }),
154 state.deployListLoading && !entries.length && muted('Loading deployments…'),
155 state.deployListError && status('error', state.deployListError),
156 ...entries.map(entry => row(`deploy-row-${entry.uid}`, [
157 inline([Text({ color: STATUS[stepKind(entry.state)].color, children: [STATUS[stepKind(entry.state)].glyph] }),
158 button(`deploy-open-${entry.uid}`, runTitle(entry), () => actions.openDeployment(entry.uid, entry.kind, 'list'), { plain: true })]),
159 caption(`${runMeta(entry)} · ${runTiming(entry)}`),
160 entry.steps?.length ? glyphs(p, entry.steps, `deploy-rail-${entry.uid}`) : null,
161 ], [badge(stateLabel(entry.state), stepKind(entry.state)), badge(kindLabel(entry))])),
162 list && !entries.length && !state.deployListLoading && empty('No deployments', 'Deployments and workflow runs of this environment appear here.'),
163 list && entries.length < list.count ? button('deploy-more', state.deployListLoading ? 'Loading…' : 'Load more', actions.moreDeployments, { plain: true, available: !state.deployListLoading }) : null,
164 inline([state.deployPaused ? caption('Auto-refresh paused after an hour.') : entries.some(isActiveRun) ? caption('Refreshing every 30 s while a run is active.') : null,
165 button('deploy-refresh', state.deployListLoading ? 'Refreshing…' : 'Refresh', actions.refreshDeployments, { plain: true, dim: true, available: !state.deployListLoading })], { justifyContent: 'flex-end' }),
166 ]);
167 }
168 const detail = state.deployDetail;
169 const back = button('deploy-back', state.deployHistory.length ? '← Workflow run' : state.deployOrigin === 'list' ? '← Deployments' : state.deployOrigin === 'home' ? '← Home' : '← Code', actions.deployBack, { plain: true, dim: true });
170 if (!detail) return column([back, muted('Loading the run…')]);
171 if (detail.hidden) return column([back, heading(detail.kind === 'workflow_run' ? 'Workflow run' : 'Deployment'), status('info', HIDDEN_DEPLOYMENT)]);
172 const run = detail.run;
173 if (!run) return column([back, heading('Deployment'), detail.error ? status('error', detail.error) : muted('Loading the run…'),
174 detail.error && button('deploy-retry', 'Try again', actions.refreshDeployments, { plain: true })]);
175 const steps = run.steps || [];
176 const selected = steps.find(step => step.id === state.deployStep) || defaultStep(run);
177 const source = logSource(run, selected), logs = state.deployLogs;
178 const current = !!logs && !source.reason && logs.runUid === source.runUid && (logs.stepUid || null) === (source.stepUid || null);
179 const ownedListed = uid => (run.deploymentRuns || []).some(owned => owned.uid === uid);
180 return column([
181 back,
182 inline([heading(runTitle(run)), badge(kindLabel(run))], { flexWrap: 'wrap' }),
183 status(stepKind(run.state), `${stateLabel(run.state)}${run.outcome ? ` · ${run.outcome.replace(/_/g, ' ')}` : ''}${run.reason ? ` · ${run.reason.replace(/_/g, ' ')}` : ''}`),
184 caption(`${runMeta(run) || 'Repository unknown'}${run.workflowPath ? ` · ${run.workflowPath}` : ''}${run.kind === 'deployment_run' && run.targetLabel ? ` · ${run.targetLabel}` : ''}`),
185 caption(`${runTiming(run)}${isActiveRun(run) ? ` · as of ${formatWhen(detail.now)}` : ''}`),
186 run.error && status('error', run.error),
187 detail.error && status('warning', detail.error),
188 run.kind === 'deployment_run' && run.workflowRun && inline([caption(`Started by workflow ${run.workflowRun.workflowPath}${run.workflowStepKeys?.length ? ` · serves ${run.workflowStepKeys.join(', ')}` : ''}`),
189 button(`deploy-workflow-${run.workflowRun.uid}`, 'Open workflow run', () => actions.openDeployment(run.workflowRun.uid, 'workflow_run', 'detail'), { plain: true })], { flexWrap: 'wrap' }),
190 // Native keys live on Boxes; the Svg is a plain image inside its keyed slot, like agent icons.
191 steps.length ? p.Box({ key: 'deploy-timeline', flexShrink: 0, children: [Svg({ source: timelineSvg(run, detail.now), alt: `Timeline of ${steps.length} ${run.kind === 'workflow_run' ? 'steps' : 'stages'} on the run's clock`, width: 360, isInteractive: false })] }) : null,
192 inline([title(run.kind === 'workflow_run' ? 'Steps' : 'Stages'), isActiveRun(run) && caption(state.deployPaused ? 'Auto-refresh paused after an hour.' : 'Refreshing every 10 s while active.')], { justifyContent: 'space-between', flexWrap: 'wrap' }),
193 ...steps.map(step => row(`deploy-step-row-${step.id}`, [
194 button(`deploy-step-${step.id}`, `${STATUS[stepKind(step.state)].glyph} ${stepLabel(step)}`, () => actions.selectDeployStep(step.id), selected?.id === step.id ? {} : { plain: true }),
195 caption([step.operation && `${step.operation}${step.resourceKey ? ` · ${step.resourceKey}` : ''}`, step.name && step.name !== step.key && step.name, step.kind, step.needs?.length && `needs ${step.needs.join(', ')}`,
196 step.reason && step.reason.replace(/_/g, ' ')].filter(Boolean).join(' · ')),
197 ], [badge(stateLabel(step.state), stepKind(step.state)), step.durationMs !== null && step.durationMs !== undefined ? badge(formatDuration(step.durationMs)) : null])),
198 !steps.length && muted('This run has no steps yet.'),
199 selected && card(`${run.kind === 'workflow_run' ? 'Step' : 'Stage'} ${stepLabel(selected)}`, [
200 line('State', stateLabel(selected.state), stepKind(selected.state)),
201 selected.startedAt && line('Started', formatWhen(selected.startedAt)),
202 selected.finishedAt && line('Finished', formatWhen(selected.finishedAt)),
203 selected.durationMs !== null && selected.durationMs !== undefined && line('Duration', formatDuration(selected.durationMs)),
204 selected.needs?.length && line('Needs', selected.needs.join(', ')),
205 selected.outcome && line('Outcome', selected.outcome.replace(/_/g, ' ')),
206 selected.reason && line(stepKind(selected.state) === 'info' ? 'Skip reason' : 'Reason', selected.reason.replace(/_/g, ' ')),
207 selected.error && status('error', selected.error),
208 selected.deploymentRunUid && (ownedListed(selected.deploymentRunUid)
209 ? button(`deploy-step-open-${selected.deploymentRunUid}`, 'Open deployment', () => actions.openDeployment(selected.deploymentRunUid, 'deployment_run', 'detail'), { plain: true })
210 : caption(HIDDEN_DEPLOYMENT)),
211 ]),
212 selected && column([
213 inline([title('Logs'), source.reason ? null : caption(source.stepUid ? stepLabel(selected) : run.kind === 'workflow_run' ? `deployment ${source.runUid.slice(0, 8)}` : 'all stages')]),
214 source.reason && muted(source.reason),
215 !source.reason && state.deployLogsLoading && (!current || !logs.entries.length) && muted('Loading logs…'),
216 current && logs.hidden && status('info', HIDDEN_DEPLOYMENT),
217 current && logs.error && status('error', logs.error),
218 current && !logs.hidden && !logs.error && (logs.entries.length ? p.Box({ key: 'deploy-logs', children: [Code({ source: logText(logs.entries), language: 'text', wrap: 'wrap' })] })
219 : muted(logs.complete ? 'No log entries for this step.' : 'No log entries yet.')),
220 current && !logs.hidden && buttons([
221 button('deploy-logs-older', 'Older', actions.deployLogsOlder, { plain: true, available: logs.previous.length > 0 && !state.deployLogsLoading }),
222 caption(`${logs.entries.length} ${logs.entries.length === 1 ? 'entry' : 'entries'}${logs.complete ? '' : ' · more available'}`),
223 button('deploy-logs-newer', 'Newer', actions.deployLogsNewer, { plain: true, available: !!logs.nextCursor && !state.deployLogsLoading }),
224 button('deploy-logs-refresh', 'Refresh', actions.refreshDeployments, { plain: true, dim: true, available: !state.deployLogsLoading }),
225 ]),
226 ], { gap: 0 }),
227 run.kind === 'workflow_run' && run.deploymentRuns?.length ? title('Deployments') : null,
228 ...(run.kind === 'workflow_run' ? run.deploymentRuns || [] : []).map(owned => row(`deploy-owned-${owned.uid}`, [
229 button(`deploy-open-${owned.uid}`, owned.targetLabel || owned.targetType || owned.uid, () => actions.openDeployment(owned.uid, 'deployment_run', 'detail'), { plain: true }),
230 caption([owned.targetType, owned.workflowStepKeys?.length && `serves ${owned.workflowStepKeys.join(', ')}`, owned.startedAt && `started ${formatWhen(owned.startedAt)}`].filter(Boolean).join(' · ')),
231 ], [badge(stateLabel(owned.state), stepKind(owned.state))])),
232 ]);
233}
234hooks/primitives.mjs 66 lines1import { clean, resultText, selectPage } from './model.mjs';
2
3/** Shared drawing helpers over the host's own elements: no custom palette, DOM or CSS. */
4export const STATUS = {
5 success: { glyph: '●', color: 'success' }, warning: { glyph: '◌', color: 'warning' },
6 error: { glyph: '!', color: 'error' }, info: { glyph: '○', color: 'subtle' },
7};
8export const OPERATION_STATUS = { running: 'Running', success: 'Completed', failure: 'Failed', cancelled: 'Cancelled', stale: 'Interrupted' };
9export const OPERATION_KIND = { running: 'warning', success: 'success', failure: 'error', cancelled: 'info', stale: 'info' };
10
11export function primitives(ui, state, actions) {
12 const { Box, Text, Button, Input, Select, Code, Markdown, Svg } = ui;
13 const text = (value, props = {}) => Text({ ...props, children: [clean(value)] });
14 const muted = (value, props = {}) => text(value, { dimColor: true, ...props });
15 const caption = (value, props = {}) => text(value, { dimColor: true, size: 'caption', wrap: 'wrap', ...props });
16 const heading = (value, props = {}) => text(value, { size: 'heading', weight: 'semibold', ...props });
17 const title = (value, props = {}) => text(value, { weight: 'semibold', wrap: 'wrap', ...props });
18 const status = (kind, value, props = {}) => text(`${STATUS[kind].glyph} ${value}`, { color: STATUS[kind].color, wrap: 'wrap', ...props });
19 const column = (children, props = {}) => Box({ flexDirection: 'column', gap: 1, ...props, children: children.filter(Boolean) });
20 const inline = (children, props = {}) => Box({ flexDirection: 'row', alignItems: 'center', gap: 1, ...props, children: children.filter(Boolean) });
21 const buttons = children => Box({ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 1, children: children.filter(Boolean) });
22 const line = (label, value, kind) => Box({ flexDirection: 'row', justifyContent: 'space-between', gap: 2,
23 children: [muted(label), kind ? status(kind, value) : text(value, { wrap: 'wrap' })] });
24 const card = (name, children, control) => Box({ flexDirection: 'column', gap: 1, paddingX: 1, paddingY: 1, borderStyle: 'round', borderDimColor: true,
25 children: [name && Box({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 1, children: [title(name), control].filter(Boolean) }), ...children].filter(Boolean) });
26 const section = (name, children) => column([name && title(name), ...children]);
27 /** `available: false` keeps the control visible, dimmed and inert, with the reason beneath it. */
28 const button = (key, label, onPress, { primary = false, plain = false, dim = false, available = true, reason = '' } = {}) => {
29 if (available) return Button({ key, label: clean(label), onPress, ...(primary ? { variant: 'primary' } : {}), ...(plain ? { plain: true } : {}), ...(dim ? { dimColor: true } : {}) });
30 const control = Button({ key, label: clean(label), disabled: true, dimColor: true, plain: true, onPress: () => {} });
31 return reason ? column([control, caption(reason)], { gap: 0 }) : control;
32 };
33 const toggle = (key, label) => Button({ key: `toggle-${key}`, label: `${state.expanded[key] ? '▾' : '▸'} ${label}`, plain: true, dimColor: true, onPress: () => actions.toggle(key) });
34 const input = (key, label, placeholder = '', extra = {}) => column([label && caption(label), Input({ key: `field-${key}`, value: state.fields[key] || '', placeholder, ...extra,
35 onInput: value => actions.field(key, value), onSubmit: value => actions.field(key, value) })], { gap: 0 });
36 /** Pages at 63 entries: Claude refuses a pane whose Select has more than 64 options. */
37 const select = (key, label, options, value, onSelect, placeholder = `Choose ${String(label || 'an option').toLowerCase()}`) => {
38 const page = selectPage(options, value, state.selectPages[key]);
39 if (!page.total) return null;
40 const choices = page.entries.some(option => option.value === value) ? page.entries : [page.selected || { value: '', label: placeholder }, ...page.entries];
41 return column([label && caption(label), Select({ key, options: choices, value: page.selected ? value : '', onSelect }),
42 page.count > 1 && inline([button(`${key}-previous`, 'Previous', () => actions.selectPage(key, page.page - 1), { plain: true, available: page.page > 0 }),
43 caption(`${page.start}–${page.end} of ${page.total}`),
44 button(`${key}-next`, 'Next', () => actions.selectPage(key, page.page + 1), { plain: true, available: page.page + 1 < page.count })])], { gap: 0 });
45 };
46 /** A list row: name and metadata on the left, inline controls on the right. */
47 const grow = child => Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [child] });
48 const row = (key, left, right = [], props = {}) => Box({ key, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 1, paddingY: 0, ...props, children: [
49 Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, minWidth: 0, gap: 0, children: left.filter(Boolean) }),
50 Box({ flexDirection: 'row', flexShrink: 0, alignItems: 'center', gap: 1, children: right.filter(Boolean) }),
51 ] });
52 const badge = (value, kind) => text(value, { size: 'caption', ...(kind ? { color: STATUS[kind].color } : { dimColor: true }) });
53 const markdown = (value, props = {}) => Markdown ? Markdown({ text: clean(value), ...props }) : text(value, { wrap: 'wrap' });
54 /** An operation result: the summary coloured by outcome, partial-effect warnings, and collapsed details. */
55 const result = key => {
56 const value = state.results[key];
57 if (!value) return null;
58 const kind = value.status === 'success' ? 'success' : value.status === 'cancelled' ? 'info' : 'error';
59 return column([status(kind, value.summary), value.effects && status('warning', value.effects.message),
60 value.data !== undefined && toggle(`result-${key}`, 'Details'),
61 state.expanded[`result-${key}`] && Code({ source: resultText(value), language: 'json', wrap: 'wrap' })], { gap: 0 });
62 };
63 const empty = (name, detail, controls = []) => column([title(name), detail && muted(detail), controls.length && buttons(controls)]);
64 return { Box, Text, Button, Input, Select, Code, Svg, grow, text, muted, caption, heading, title, status, column, inline, buttons, line, card, section, button, toggle, input, select, row, badge, markdown, result, empty };
65}
66hooks/agentView.mjs 82 lines1import { clean, record, rows } from './model.mjs';
2import { primitives } from './primitives.mjs';
3import { renderAgentIcon } from './agentIcon.mjs';
4
5const RUNTIME = { current: 'Up to date', not_deployed: 'Not deployed', updating: 'Updating', update_required: 'Update available', update_failed: 'Update failed', unknown: 'Status unknown' };
6
7export function renderAgents(ui, state, actions) {
8 const p = primitives(ui, state, actions);
9 const { Box, Input, text, muted, caption, heading, title, status, column, inline, buttons, button, row, badge, markdown, empty } = p;
10 const defaults = agent => {
11 const values = [agent.defaults?.provider, agent.defaults?.model, agent.defaults?.thinking].map(value => clean(value)).filter(Boolean);
12 return values.length ? caption(values.join(' · '), { key: `agent-defaults-${agent.uid}` }) : null;
13 };
14 const bindingFor = agent => state.agentBindings[JSON.stringify([state.fields.backend, state.fields.mcpEnvironment, state.agentAccount, agent.uid])];
15 const canTalk = agent => agent.runtimeState !== 'not_deployed' && agent.responseKinds?.some(kind => ['message', 'task'].includes(kind));
16 const isActive = agent => state.activeAgent?.agent?.uid === agent.uid;
17 const unavailableReason = agent => agent.runtimeState === 'not_deployed' ? 'Deploy this agent to start a conversation.' : 'This agent does not advertise messaging support.';
18 const talk = agent => button(`agent-talk-${agent.uid}`, 'Talk', () => actions.talkToAgent(agent.uid), { primary: isActive(agent), available: canTalk(agent) });
19 /** One status line per agent: preparation phase, then the conversation's own state. */
20 const agentStatus = agent => {
21 const preparing = state.agentPreparing[agent.uid], binding = bindingFor(agent);
22 if (preparing) return ['warning', preparing.phase];
23 if (!binding) return null;
24 if (binding.uncertain && !binding.busy) return ['error', 'Delivery needs review'];
25 return ({ opening: ['warning', 'Opening session…'], selected: ['success', 'Agent ready · no message sent'], waiting: ['warning', 'Waiting for agent…'],
26 ready: ['success', 'Reply received'], working: ['warning', 'Agent task in progress'], failed: ['error', 'Conversation needs attention'] })[binding.status] || null;
27 };
28 const statusRow = agent => {
29 const value = agentStatus(agent), binding = bindingFor(agent), preparing = state.agentPreparing[agent.uid];
30 if (!value) return null;
31 return inline([status(value[0], value[1], { key: `agent-status-${agent.uid}` }),
32 preparing && button(`agent-prepare-cancel-${agent.uid}`, 'Cancel', () => actions.cancelAgentPreparation(agent.uid), { plain: true, dim: true }),
33 !preparing && binding?.status === 'failed' && button(`agent-prepare-retry-${agent.uid}`, 'Try again', () => actions.talkToAgent(agent.uid), { plain: true })], { flexWrap: 'wrap' });
34 };
35 const notes = agent => {
36 const binding = bindingFor(agent), preparing = state.agentPreparing[agent.uid];
37 return [!preparing && binding?.error && caption(binding.error, { color: 'error' }),
38 binding?.task && caption(`Task ${binding.task.status}${binding.task.cancellationRequested ? ' · cancellation requested' : ''}`),
39 binding?.uncertain && !preparing && inline([caption('The previous request may have reached the agent. Review Activity before sending again.'),
40 !binding.busy && button(`agent-resend-${agent.uid}`, 'Allow sending again', () => actions.allowAgentResend(agent.uid), { plain: true })], { flexWrap: 'wrap' })].filter(Boolean);
41 };
42 const detail = state.agentDetail;
43 if (detail) {
44 const examples = (detail.skills || []).flatMap(skill => skill.examples || []).slice(0, 8);
45 return column([
46 button('agents-back', '← Agents', actions.closeAgentDetails, { plain: true, dim: true }),
47 inline([renderAgentIcon(ui, state, detail.uid, { size: 32 }), heading(detail.name)]),
48 defaults(detail),
49 inline([badge(RUNTIME[detail.runtimeState] || RUNTIME.unknown, detail.runtimeState === 'current' ? 'success' : detail.runtimeState === 'not_deployed' || detail.runtimeState === 'update_failed' ? 'error' : undefined),
50 caption(`${detail.environmentName} · ${detail.repositoryBranch || 'Branch unavailable'}`)]),
51 detail.description && markdown(detail.description),
52 statusRow(detail) || (!canTalk(detail) ? caption(unavailableReason(detail)) : null), ...notes(detail),
53 buttons([talk(detail), button('agent-new-conversation', 'New conversation…', () => actions.newAgentConversation(detail.uid), { plain: true, dim: true })]),
54 caption('Talk opens or resumes this agent’s conversation and prepares Claude’s input. Only the message you submit is sent.'),
55 title('Skills'),
56 ...(detail.skills?.length ? detail.skills.map(skill => column([text(skill.name, { weight: 'medium' }), skill.description && caption(skill.description)], { gap: 0 })) : [muted('The agent card does not list individual skills. Ask the agent for details.')]),
57 examples.length ? title('Try asking') : null,
58 ...examples.map((example, index) => button(`agent-example-${detail.uid}-${index}`, `“${example}”`, () => actions.askAgent(detail.uid, example), { plain: true, available: canTalk(detail) })),
59 ]);
60 }
61 const result = state.results['devtools.agents-list'], page = record(result?.data);
62 const agents = rows(page).filter(agent => agent.environmentUid === state.fields.mcpEnvironment);
63 return column([
64 inline([Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [Input({ key: 'field-agentSearch', value: state.fields.agentSearch, placeholder: 'Search agents', autoFocus: true, onInput: value => actions.field('agentSearch', value), onSubmit: value => { actions.field('agentSearch', value); actions.searchAgents(); } })] }),
65 button('agents-search', 'Search', actions.searchAgents, { plain: true }),
66 button('agents-refresh', state.agentsLoading ? 'Loading…' : 'Refresh', actions.refreshAgents, { plain: true, dim: true, available: !state.agentsLoading })]),
67 result?.status === 'failure' && column([status('error', result.summary), button('agents-retry', 'Try again', actions.refreshAgents, { plain: true })]),
68 state.agentsLoading && muted('Loading agents from this environment…'),
69 result?.status === 'success' && !agents.length && empty(state.fields.agentSearch.trim() ? 'No matching agents' : 'No agents in this environment', 'Try another search or choose another environment.'),
70 result?.status === 'success' && agents.length ? caption(page.semantic ? `${agents.length} matching agents` : `${page.count} agents · showing ${state.agentOffset + 1}–${state.agentOffset + agents.length}`) : null,
71 ...agents.map(agent => column([
72 row(`agent-${agent.uid}`, [
73 inline([renderAgentIcon(ui, state, agent.uid, { size: 20 }), button(`agent-details-${agent.uid}`, agent.name, () => actions.agentDetails(agent.uid), { plain: true })]),
74 defaults(agent),
75 statusRow(agent) || (!canTalk(agent) ? caption(unavailableReason(agent)) : null),
76 ...notes(agent),
77 ], [talk(agent)]),
78 ], { gap: 0 })),
79 !page.semantic && agents.length ? inline([state.agentOffset > 0 && button('agents-previous', 'Previous', actions.previousAgents, { plain: true }), state.agentOffset + agents.length < page.count && button('agents-next', 'Next', actions.nextAgents, { plain: true })]) : null,
80 ]);
81}
82hooks/projectView.mjs 59 lines1import { clean, allOperations } from './model.mjs';
2import { primitives } from './primitives.mjs';
3
4export function renderProjects(ui, state, actions) {
5 const p = primitives(ui, state, actions);
6 const { Box, Input, text, muted, caption, heading, title, status, column, inline, buttons, button, row, badge, markdown, empty } = p;
7 const page = state.projectPage, selected = state.projectDetail;
8 const operation = state.results['devtools.organization-project-setup'];
9 const setupOp = allOperations(state).find(op => op.status === 'running' && op.label === 'devtools · organization-project-setup');
10 if (selected && !state.projectListView) {
11 const groups = new Map();
12 for (const branch of selected.branches || []) {
13 if (!groups.has(branch.code_repository_uid)) groups.set(branch.code_repository_uid, []);
14 groups.get(branch.code_repository_uid).push(branch);
15 }
16 const hasCommandCenter = !!state.fields.commandCenterUrl.trim();
17 const canSetup = !!selected.branches.length && !!state.fields.baseDir && !state.projectSetupRunning;
18 return column([
19 button('projects-back', '← Projects', actions.showProjectList, { plain: true, dim: true }),
20 heading(selected.project.name),
21 selected.project.description && markdown(selected.project.description),
22 buttons([
23 state.projectWorkspace ? button('project-open-local', 'Open in Claude', actions.openOrganizationWorkspace, { primary: true }) : null,
24 button('project-setup', state.projectSetupRunning ? 'Setting up…' : state.projectWorkspace ? 'Update local workspace' : 'Set up locally', actions.setupOrganizationProject,
25 { primary: !state.projectWorkspace, available: canSetup, reason: !selected.branches.length ? 'This project has no accessible branches.' : !state.fields.baseDir ? 'Choose a repositories folder in Settings first.' : '' }),
26 button('project-open', 'Open in Command Center', () => actions.projectLink('project'), { plain: true, available: hasCommandCenter, reason: 'Set the Command Center URL in Settings to open platform pages.' }),
27 ]),
28 state.projectWorkspace && caption(`Local workspace: ${state.projectWorkspace.root}`),
29 setupOp && status('warning', setupOp.progress),
30 operation?.status === 'failure' && status('error', operation.summary),
31 operation?.effects && status('warning', operation.effects.message),
32 state.projectOpenMessage && status('info', state.projectOpenMessage),
33 title('Repositories'),
34 !hasCommandCenter && caption('Repository and branch links open GitHub.'),
35 ...[...groups].map(([uid, branches]) => column([
36 row(`project-repository-row-${uid}`, [button(`project-repository-${uid}`, `${branches[0].code_repository_name} ↗`, () => actions.projectLink('repository', branches[0].uid), { plain: true })], []),
37 ...branches.map(branch => row(`project-branch-row-${branch.uid}`, [inline([caption(' '), button(`project-branch-${branch.uid}`, `${branch.repository_branch} ↗`, () => actions.projectLink('branch', branch.uid), { plain: true, dim: true })])],
38 [badge(branch.is_initialized ? 'Ready' : 'Initializing', branch.is_initialized ? 'success' : 'warning')])),
39 ], { gap: 0 })),
40 ]);
41 }
42 return column([
43 inline([Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [Input({ key: 'field-projectSearch', value: state.fields.projectSearch, placeholder: 'Search projects', autoFocus: true, onInput: value => actions.field('projectSearch', value), onSubmit: actions.refreshOrganizationProjects })] }),
44 button('projects-search', 'Search', actions.refreshOrganizationProjects, { plain: true, available: !state.projectsLoading }),
45 button('projects-refresh', state.projectsLoading ? 'Loading…' : 'Refresh', actions.refreshOrganizationProjects, { plain: true, dim: true, available: !state.projectsLoading })]),
46 state.projectsLoading && muted('Loading projects…'),
47 state.projectError && status('error', state.projectError),
48 page && !(page.results || []).length && empty('No projects', 'No projects match this environment and search.'),
49 ...(page?.results || []).map(project => row(`project-row-${project.uid}`, [
50 button(`project-${project.uid}`, project.name, () => actions.chooseProject(project.uid), { plain: true }),
51 project.description && caption(project.description),
52 ], [badge(`${project.code_repository_branch_uids.length} ${project.code_repository_branch_uids.length === 1 ? 'branch' : 'branches'}`),
53 project.uid === state.selectedProject && state.projectWorkspace ? badge('Local', 'success') : null])),
54 state.projectLoading && muted('Loading project repositories and branches…'),
55 page && page.count > (page.results || []).length ? inline([button('projects-previous', 'Previous', actions.previousProjects, { plain: true, available: page.offset > 0 && !state.projectsLoading }),
56 caption(`${page.count} projects`), button('projects-next', 'Next', actions.nextProjects, { plain: true, available: page.offset + (page.results || []).length < page.count && !state.projectsLoading })]) : page ? caption(`${page.count} ${page.count === 1 ? 'project' : 'projects'}`) : null,
57 ]);
58}
59hooks/release.mjs 3 lines1// scripts/build-claude.cjs writes the package version here in the built plugin; a source checkout has none.
2export const PLUGIN_VERSION = '';
3