Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1972,11 +1972,14 @@ def get_default_local_shell_config():

shell = os.environ.get('SHELL') or '/bin/sh'
terminal_kind = get_shell_kind(shell)
shell_command = [shell]
if sys.platform == 'darwin' and app.config.get('DESKTOP_LOGIN_SHELL') and terminal_kind != 'shell':
shell_command.append('-l')
return {
'shell_kind': terminal_kind,
'terminal_kind': terminal_kind,
'terminal_label': get_shell_label(terminal_kind),
'shell_command': [shell],
'shell_command': shell_command,
'shell_display': shell,
}, None

Expand Down Expand Up @@ -6850,6 +6853,22 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None):
command_endpoint = command_base_url.rstrip('/') + '/agent/external/command'
handoff_path = EXTERNAL_AGENT_HANDOFF_PATH
terminal_handoffs = build_external_agent_terminal_handoff_index()
skills = {}
for name, directory in (
('standterm-external-agent', 'standterm-external-agent-skill'),
('standterm-file-transfer', 'standterm-file-transfer'),
('standterm-privileged-hitl', 'standterm-privileged-hitl'),
):
skill_dir = APP_DIR / 'docs' / 'examples' / directory
paths = {
'path': skill_dir / 'SKILL.md',
'boot_prompt_path': skill_dir / 'boot_prompt.txt',
'install_prompt_path': skill_dir / 'skill_prompt.txt',
}
skills[name] = {
**{key: str(value) for key, value in paths.items()},
'available': all(value.is_file() for value in paths.values()),
}
transport = {
'type': 'loopback_http_json',
'base_url': command_base_url,
Expand All @@ -6864,6 +6883,7 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None):
payload = {
'schema': 'standterm_agentinfo',
'schema_version': 1,
'instance_id': LAUNCHER_INSTANCE_ID,
'protocol_version': EXTERNAL_AGENT_PROTOCOL_VERSION,
'generated_at': time.time(),
'base_url': command_base_url,
Expand All @@ -6888,11 +6908,11 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None):
'agent_scp': str(APP_DIR / 'scripts' / 'agent_scp.py'),
'agent_shcmd': str(APP_DIR / 'scripts' / 'agent_shcmd.py'),
'agent_type': str(APP_DIR / 'scripts' / 'agent_type.py'),
'agent_rsfile': str(APP_DIR / 'scripts' / 'agent_rsfile.py'),
'agent_mcp': str(APP_DIR / 'scripts' / 'agent_mcp.py'),
},
'skill': {
'path': str(APP_DIR / 'docs' / 'examples' / 'standterm-external-agent-skill' / 'SKILL.md'),
'boot_prompt_path': str(APP_DIR / 'docs' / 'examples' / 'standterm-external-agent-skill' / 'boot_prompt.txt'),
},
'skill': skills['standterm-external-agent'],
'skills': skills,
'capabilities': list(EXTERNAL_AGENT_CAPABILITIES),
'recommended_commands': build_external_agentinfo_recommended_commands(
agentinfo_path=agentinfo_path,
Expand Down
100 changes: 95 additions & 5 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,66 @@

This Electron evaluation owns a Python backend and opens the existing StandTerm
UI without asking the local operator to paste an access token. It supports a
source-run workflow and an unsigned Windows x64 evaluation installer. It is not
source-run workflow, an unsigned Windows x64 evaluation installer and a native
Apple Silicon macOS evaluation app/DMG. It is not
a production release or a replacement for `run.sh` / `run.bat`.

## macOS Apple Silicon evaluation

The arm64 DMG contains `StandTermDesktop.app`, Electron and the verified Core
snapshot. Copy the app to a user-owned Applications folder before launching it.
The evaluation uses ad-hoc signing, without Developer ID or notarization; it is
not a Gatekeeper-qualified public release. No signing account or private key is
needed for a local build. Intel/Rosetta acceptance is not implied.

Native macOS mode is selected automatically, or explicitly with `--backend=macos`.
Local shells start as login shells so their usual profiles (for example
`~/.zprofile` for zsh) supply MacPorts/Homebrew and user command paths even when
the app starts from Finder. The browser/Core launcher's shell behavior is unchanged.
First launch locates an existing native Python 3.10+ with venv/ensurepip support
in MacPorts, Homebrew or PATH, or offers a file chooser. Python must match the
app architecture. The Apple `/usr/bin/python3` developer-tools stub is skipped;
StandTerm does not install Python, package managers, Rosetta or OS components.

After confirmation, setup copies Core and prepares its venv under
`~/Library/Application Support/StandTermDesktop/runtimes/<bundle-sha256>/`.
The interpreter is `tools/.venv_macos/bin/python`. Launcher settings, saved port,
diagnostics and the browser profile live under
`~/Library/Application Support/StandTermDesktopEvaluation/macos/`.
All mutable state stays outside the `.app`. Later launches verify and reuse the
runtime. Setup cancellation retains partial files for retry; a changed Core
bundle uses a new directory. Removing the app retains user data and runtimes.
The Windows installer's optional cleanup is not a macOS uninstall action.

Build on an Apple Silicon Mac with Node 22.12+ and the checkout's macOS venv:

```sh
cd desktop
npm ci
npm run stage:mac
# Change to the absolute stage directory printed above, then:
npm ci
npm run make:mac
```

Staging shares the tracked-file allowlist used by Windows, excludes internal
documents and Git state, and generates the native icon with macOS `sips` and
`iconutil`. macOS outputs are in the stage's `out.noindex/` directory so local
development app copies stay out of Spotlight results. The current build uses
electron-builder 26's [macOS signing options](https://www.electron.build/v26/docs/mac/).

For isolated verification, run `npm test`, `npm run smoke:capture`, the Python
bootstrap/runtime/backend tests, and `test/browser-storage-smoke.cjs` with the
checkout Electron executable. Set separate `STANDTERM_AGENT_RUNTIME_DIR` and
`STANDTERM_SESSION_RECOVERY_STORE` paths for smoke runs. Use a canonical macOS
temporary root, for example `TMPDIR=/private/tmp`, for the complete Core suite.
`test/macos-setup-integration.cjs`, launched by checkout Electron with the prepared
venv Python and stage path as arguments, exercises the real first-run progress
window and bootstrap in a disposable runtime. Its setup choice is scripted.
Smoke camera/microphone denial probes use synthetic media devices, keeping them
independent of the operator's physical audio/video hardware; permission checks
remain enabled. Real device/OS permission behavior still needs manual acceptance.

## Windows x64 evaluation installer

The installer includes Electron and a SHA-256-manifested snapshot of the public
Expand Down Expand Up @@ -152,7 +209,7 @@ that uncommitted changes are already a GitHub release. Build staging never copie
the development venv or `node_modules`. The new directory gets Windows build
dependencies; the source checkout's Linux/WSLg `node_modules` is untouched.

The `StandTerm-Desktop-0.4.1-win32-x64-Setup.exe` is under `out/`; the unpacked
The `StandTerm-Desktop-0.4.3-win32-x64-Setup.exe` is under `out/`; the unpacked
application is under `out/win-unpacked/`. Packaging uses
[electron-builder's assisted NSIS target](https://www.electron.build/nsis.html),
with pinned build dependencies and scoped custom installer hooks. Squirrel
Expand Down Expand Up @@ -334,6 +391,37 @@ cookies, Node access, preload or IPC bridge. A one-use main-process grant select
only the existing StandTerm main frame; it never enumerates desktop windows or
grants camera/microphone access. The terminal page cannot initiate capture.

## Agent prompts and bundled Core

The top-level **Agent** menu is the first-use entrypoint; no prior StandTerm
skill installation is required. Choose **Copy skill installation prompt** and
paste it into your agent. For subsequent sessions, use **Copy usage prompt** or
**Copy file-transfer prompt** and describe the intended task and terminal.
**Getting started...** explains token minting and the distinction between setup
and terminal authority. **Copy connection info (JSON)** and **Copy agentinfo URL**
remain available for clients that already know the protocol. Existing Diagnostics
copy actions are preserved.

These actions only copy text or show help. They do not install skills, execute
helpers, mint tokens or approve transfers. Each prompt contains the exact live
endpoint and instance ID, never credentials. Agents verify `/agentinfo` identity
and use its `launch_dir`, `python_path`, `scripts` and `skills` paths. Installation
prompts ask agents to preserve references and customized skills; agents without
persistent skill support can read the documents for the current session instead.
Backend paths belong to the active macOS, Windows or WSL environment. If access fails or
an older Core lacks discovery metadata, request the correct environment or a
Core update; do not guess a different endpoint or download arbitrary helpers.

Core staging includes the published runtime, static assets, launch/install
scripts, README, public documentation, skill prompts/references and support
helpers. Only Git-tracked release inputs are selected; developer venvs, private
handoffs, profiles, credentials and unpublished rescue tools are not included.
`core-files.cjs` checks required inputs and relative skill links before and after
staging. The extracted-package inspector repeats the checks alongside manifest
hash verification, so missing skill documents or helpers fail packaging checks.
The Agent menu and expanded Core payload require a new installer build; existing
0.4.1 installers do not gain them automatically.

## Authentication and security boundary

**StandTerm > About StandTerm Desktop** lists Desktop and the running Core
Expand All @@ -342,9 +430,11 @@ managed Core bundle SHA-256 identity when available. The same Core details are
in Diagnostics. Core reports its version from `core_version.py`, independently
of the Electron package version. Source checkouts have no managed build identity;
older backends that omit version metadata show Unknown, never an inferred Git
tag. The current source candidate is Desktop 0.4.1 / Core 2.11.0-dev, not a
published stable release. Version and copy-info additions postdate the delivered
0.4.0 installer; they require a new build.
tag. The current source candidate is Desktop 0.4.3 / Core 2.11.0-dev, not a
published stable release. The Agent menu and expanded Core payload postdate the
published 0.4.1 installer and the earlier macOS 0.4.2 candidate; they require a
new build. The integrated macOS candidate retains native setup, login shells
and arm64 DMG packaging alongside these additions.

The native **Diagnostics** menu shows the actual backend URL (IP and port),
backend mode, Desktop version and web-settings storage mode. It opens
Expand Down
45 changes: 45 additions & 0 deletions desktop/agent-menu.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict';

const { agentConnectionInfo } = require('./diagnostics.cjs');

const INTRO = 'Read the agentinfo_url in the connection JSON below and verify instance_id before proceeding. '
+ 'Use launch_dir, python_path, scripts and skill/skills from that exact instance; do not guess ports or runtime paths. '
+ 'Read the bundled skill entrypoint and only the references needed for this task. '
+ 'Paths belong to the backend environment (macOS, Windows or WSL): if access or the endpoint is unavailable, report the limitation '
+ 'and ask for the correct environment or fresh connection information instead of scanning for another instance. ';
const BOUNDARY = 'Never expose handoff secrets or credentials. Skill installation is not permission to operate terminals. '
+ 'Preserve token minting, terminal modes, human-input boundaries and fresh per-copy browser approval. '
+ 'If no terminal is enabled, ask the user to enable External Agent and mint its token in the intended tab. '
+ 'Do not execute terminal commands until the user specifies the task and target.';
const PROMPTS = {
usage: 'Help me use this StandTerm instance for my stated task. ' + INTRO
+ 'Use the skill boot_prompt_path for routine operation; do not install or overwrite local skills in this workflow. ',
install: 'Help me install or update the bundled StandTerm skills for this agent. ' + INTRO
+ 'Read each available skill install_prompt_path (skill_prompt.txt), including external-agent, file-transfer and privileged-HITL guidance. '
+ 'Use this agent\'s supported skill installation mechanism, preserving references and relative paths. '
+ 'Compare existing installations first and ask before overwriting customized content. '
+ 'If persistent skills are unsupported, read the documents for this session and report that nothing was installed. '
+ 'Use helpers from the active Core with its reported Python, rather than copying them into the skill installation. ',
transfer: 'Help me prepare a StandTerm file transfer. ' + INTRO
+ 'Read the external-agent and file-transfer skills. Ask for any missing source, destination and file details. '
+ 'Prefer the typed backend copy helper; do not automate the human Files UI or approve the copy yourself. '
+ 'Do not fall back to terminal-stream rescue without a new explicit instruction. ',
};

function agentMenu({ origin, mode, instanceId, copyText, showHelp }) {
const info = agentConnectionInfo({ origin, mode, instanceId });
const json = JSON.stringify(info, null, 2);
const prompt = kind => PROMPTS[kind] + BOUNDARY + '\n\n' + json;
return { id: 'agent-menu', label: 'Agent', submenu: [
{ id: 'agent-help', label: 'Getting started...', click: showHelp },
{ type: 'separator' },
{ id: 'agent-copy-usage', label: 'Copy usage prompt', click: () => copyText(prompt('usage')) },
{ id: 'agent-copy-install', label: 'Copy skill installation prompt', click: () => copyText(prompt('install')) },
{ id: 'agent-copy-transfer', label: 'Copy file-transfer prompt', click: () => copyText(prompt('transfer')) },
{ type: 'separator' },
{ id: 'agent-copy-connection', label: 'Copy connection info (JSON)', click: () => copyText(json) },
{ id: 'agent-copy-agentinfo', label: 'Copy agentinfo URL', click: () => copyText(info.agentinfo_url) },
] };
}

module.exports = { agentMenu };
3 changes: 3 additions & 0 deletions desktop/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ def main():
sys.argv = [str(root / 'app.py')]
import app as standterm
standterm.app.config['DESKTOP_FLOATING_WINDOWS'] = True
# Finder starts apps without the login-shell environment used by Terminal.
# Let each local shell load its own profile (MacPorts/Homebrew/user PATH).
standterm.app.config['DESKTOP_LOGIN_SHELL'] = sys.platform == 'darwin'
from server_startup import address_in_use, bound_server, suggested_port

# Bind before sharing credentials. The parent decides whether to retry a
Expand Down
3 changes: 1 addition & 2 deletions desktop/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ def linked(path):


def environment_python(root):
return (root / 'tools' / '.venv_win' / 'Scripts' / 'python.exe' if WINDOWS
else root / 'tools' / '.venv_wsl' / 'bin' / 'python')
return runtime.venv_path(root) / ('Scripts/python.exe' if WINDOWS else 'bin/python')


def stop_child(process):
Expand Down
2 changes: 1 addition & 1 deletion desktop/browser-session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const { randomUUID } = require('node:crypto');

function browserSessionOptions(mode, temporary = false) {
if (!['windows', 'wsl'].includes(mode)) throw new Error('Invalid browser profile mode.');
if (!['windows', 'wsl', 'macos'].includes(mode)) throw new Error('Invalid browser profile mode.');
return {
partition: temporary ? `standterm-test-${randomUUID()}` : `persist:standterm-ui-${mode}-v1`,
options: { cache: false },
Expand Down
42 changes: 42 additions & 0 deletions desktop/core-files.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const SKILL_DIRS = ['standterm-external-agent-skill', 'standterm-file-transfer', 'standterm-privileged-hitl'];
const REQUIRED = ['app.py', 'core_version.py', 'requirements.txt', 'README.md', 'run.sh', 'run.bat',
'run.command', 'run_at_wsl.bat', 'run_at_wsl+screen.bat', 'install.sh', 'install.ps1', 'install.command',
'LICENSE', 'THIRD-PARTY-NOTICES.md', 'desktop/backend.py', 'desktop/runtime.py', 'docs/agent_socket_contract.md',
'docs/backend_plugin_contract.md', 'docs/venv_prompt.txt',
...['cli', 'jsonl', 'repl', 'shcmd', 'scp', 'type', 'rsfile', 'mcp'].map(name => `scripts/agent_${name}.py`),
...SKILL_DIRS.flatMap(name => ['SKILL.md', 'boot_prompt.txt', 'skill_prompt.txt']
.map(file => `docs/examples/${name}/${file}`)),
...['connection', 'clients', 'terminal-workflows']
.map(name => `docs/examples/standterm-external-agent-skill/references/${name}.md`),
];

function coreFiles(tracked) {
return tracked.filter(file => /^[^/]+\.py$/.test(file)
|| /^(static|templates|terminal_backends|scripts)\//.test(file)
|| REQUIRED.includes(file)).sort();
}

function validateCoreFiles(root, files) {
const selected = new Set(files);
for (const file of REQUIRED) {
if (!selected.has(file)) throw new Error(`Missing required Core input: ${file}`);
}
for (const file of files) {
if (!fs.lstatSync(path.join(root, file)).isFile()) throw new Error(`Not a regular Core input: ${file}`);
if (!file.startsWith('docs/examples/') || !file.endsWith('.md')) continue;
const text = fs.readFileSync(path.join(root, file), 'utf8');
for (const match of text.matchAll(/\[[^\]]*\]\(([^\s)]+)\)/g)) {
const link = match[1].split('#')[0];
if (!link || /^[a-z][a-z0-9+.-]*:/i.test(link)) continue;
const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), link));
if (!selected.has(target)) throw new Error(`Missing bundled skill reference: ${file} -> ${link}`);
}
}
}

module.exports = { coreFiles, validateCoreFiles, REQUIRED };
15 changes: 10 additions & 5 deletions desktop/desktop-mode.cjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
'use strict';

const MODES = Object.freeze({ windows: 'StandTerm Desktop', wsl: 'StandTerm Desktop (WSL)' });
const WINDOWS_MODES = Object.freeze({ windows: 'StandTerm Desktop', wsl: 'StandTerm Desktop (WSL)' });
const MODES = Object.freeze({ ...WINDOWS_MODES, macos: 'StandTerm Desktop' });
const APP_ID = 'com.squirrel.StandTermDesktopEvaluation.StandTermDesktopEvaluation';

function desktopMode(argv) {
function desktopMode(argv, platform = process.platform) {
const values = argv.filter(value => value.startsWith('--backend='));
if (values.length > 1 || (values.length && !Object.hasOwn(MODES, values[0].slice(10)))) {
throw new Error('Use exactly one supported backend: --backend=windows or --backend=wsl.');
throw new Error('Use exactly one supported backend: --backend=windows, --backend=wsl or --backend=macos.');
}
return values.length ? values[0].slice(10) : 'windows';
const mode = values.length ? values[0].slice(10) : platform === 'darwin' ? 'macos' : 'windows';
if ((platform === 'darwin' && mode !== 'macos') || (platform !== 'darwin' && mode === 'macos')) {
throw new Error('The macOS backend requires native macOS; Windows and WSL modes require Windows.');
}
return mode;
}

module.exports = { MODES, APP_ID, desktopMode };
module.exports = { MODES, WINDOWS_MODES, APP_ID, desktopMode };
Loading
Loading