SLOPSHOPPER

mainsequence

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

panebandspinnerrowsguardcommand
A shopper browsing a rack in a slop shop
README

MainSequence for Claude Desktop

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.

Install

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.

  1. In Claude Desktop open Customize → Plugins → Add marketplace → Add from repository and enter https://github.com/mainsequence-sdk/mainsequence-claude-desktop.git.
  2. Install MainSequence from mainsequence-desktop. The catalog points at a versioned ZIP with a pinned SHA-256; nothing is built or downloaded from npm on your machine.
  3. Enable the plugin in a local Code session and press MainSequence in the session footer to open the pane. Opening it submits no prompt and saves nothing. /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.

Sign in and choose an environment

  1. Press Settings in the pane header and choose Sign in with browser. The hosted MainSequence login opens in your browser; account creation is part of that flow. The session is saved in your operating system's credential store (the login Keychain on macOS, Secret Service on Linux), where the VS Code extension, the Codex plugin and the MainSequence command lines find it too. Check connection, and under Manage account Renew session and Sign out, are in the same place.
  2. The backend defaults to 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.
  3. Choose an organization environment in the pane header. Nothing is selected for you, not even Production. Agents, MCPs, projects and deployments are shown for that environment, and the MainSequence tools Claude calls are bound to it.

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.

What you can do

  • Home. A Next steps card until you have signed in, chosen an environment and a repositories folder; This session with the Code session's folder, Check status and Update SDK when one is available; and the last three operations of every session.
  • Code. The session folder, every repository of your account (marked Local when a canonical checkout exists) and other local copies under the repositories folder. Set up clones a repository into <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.
  • Projects. The environment's MainSequence projects with their repository branches. Set up project locally prepares every member branch in its own checkout, opens the workspace in Claude through its native folder link, and adds each checkout to the new session with /add-dir, without a model turn.
  • Agents. The environment's agents with descriptions, skills and example questions. Talk opens or resumes the agent's conversation, waits for it to be ready and fills Claude's input with /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.
  • Tools. Connected project MCPs of the environment first; Add MCP searches its releases. Every tool of a connected MCP is registered for Claude as 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.
  • Activity. Running and finished operations with progress, results, cancellation and partial-effect warnings. The running count also appears in Claude's status line.

The user guide describes every section, and agent icons how the platform's icons are shown.

Troubleshooting

  • No pane. Make sure it is a local Code session with the plugin enabled, then press MainSequence in the footer or type /ms.
  • Worker failures. Open Settings → Advanced → Runtime & diagnostics.
  • An operation failed. Inspect Activity before retrying; a cancelled operation can leave partial effects on disk and is never replayed automatically.
  • After an update. Fully close old Code sessions and start fresh ones; a running old plugin process keeps its old hooks until it exits.
  • Another backend's data is shown. It is not: backend changes clear stale connection and repository results.
  • Disabling or uninstalling the plugin does not revoke your MainSequence session or remove any project; use Sign out for that.

Signed terminals, changing the Code session's root folder and launching container sessions are host limitations: the pane shows them disabled with the reason.

Development

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.

Source 11 files
hooks/register.mjs 1747 lines
1import { 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 lines
1export 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}
140
hooks/view.mjs 398 lines
1import { 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}
398
hooks/branding.mjs 3 lines
1// 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>";
3
hooks/workspace.mjs 10 lines
1// 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
10
hooks/agentIcon.mjs 38 lines
1/** 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}
38
hooks/deploymentView.mjs 234 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
234
hooks/primitives.mjs 66 lines
1import { 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}
66
hooks/agentView.mjs 82 lines
1import { 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}
82
hooks/projectView.mjs 59 lines
1import { 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}
59
hooks/release.mjs 3 lines
1// scripts/build-claude.cjs writes the package version here in the built plugin; a source checkout has none.
2export const PLUGIN_VERSION = '';
3