@aion0/forge 0.5.20 → 0.5.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/agent-context.json +1 -1
- package/RELEASE_NOTES.md +32 -6
- package/app/api/code/route.ts +10 -4
- package/app/api/plugins/route.ts +75 -0
- package/components/Dashboard.tsx +1 -0
- package/components/PipelineEditor.tsx +135 -9
- package/components/PluginsPanel.tsx +472 -0
- package/components/ProjectDetail.tsx +36 -98
- package/components/SessionView.tsx +4 -4
- package/components/SettingsModal.tsx +160 -66
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +371 -87
- package/lib/agents/index.ts +7 -4
- package/lib/builtin-plugins/docker.yaml +70 -0
- package/lib/builtin-plugins/http.yaml +66 -0
- package/lib/builtin-plugins/jenkins.yaml +92 -0
- package/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/lib/builtin-plugins/playwright.yaml +111 -0
- package/lib/builtin-plugins/shell-command.yaml +60 -0
- package/lib/builtin-plugins/slack.yaml +48 -0
- package/lib/builtin-plugins/webhook.yaml +56 -0
- package/lib/forge-mcp-server.ts +116 -2
- package/lib/pipeline.ts +62 -5
- package/lib/plugins/executor.ts +347 -0
- package/lib/plugins/registry.ts +228 -0
- package/lib/plugins/types.ts +103 -0
- package/lib/project-sessions.ts +7 -2
- package/lib/session-utils.ts +7 -3
- package/lib/terminal-standalone.ts +6 -34
- package/lib/workspace/agent-worker.ts +1 -1
- package/lib/workspace/orchestrator.ts +414 -136
- package/lib/workspace/presets.ts +5 -3
- package/lib/workspace/session-monitor.ts +14 -10
- package/lib/workspace/types.ts +3 -1
- package/lib/workspace-standalone.ts +38 -21
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/qa/.forge/agent-context.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Registry — load, install, uninstall, list plugins.
|
|
3
|
+
*
|
|
4
|
+
* Plugins are stored as YAML files:
|
|
5
|
+
* - Built-in: lib/builtin-plugins/*.yaml
|
|
6
|
+
* - User-installed: ~/.forge/plugins/<id>/plugin.yaml
|
|
7
|
+
*
|
|
8
|
+
* Installed plugin configs are stored in:
|
|
9
|
+
* - ~/.forge/data/plugin-configs.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import YAML from 'yaml';
|
|
17
|
+
import type { PluginDefinition, InstalledPlugin, PluginSource } from './types';
|
|
18
|
+
|
|
19
|
+
const _filename = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
|
|
20
|
+
const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(_filename);
|
|
21
|
+
|
|
22
|
+
const BUILTIN_DIR_COMPILED = join(_dirname, '..', 'builtin-plugins');
|
|
23
|
+
const BUILTIN_DIR_SOURCE = join(process.cwd(), 'lib', 'builtin-plugins');
|
|
24
|
+
const BUILTIN_DIR = existsSync(BUILTIN_DIR_COMPILED) ? BUILTIN_DIR_COMPILED : BUILTIN_DIR_SOURCE;
|
|
25
|
+
const USER_PLUGINS_DIR = join(homedir(), '.forge', 'plugins');
|
|
26
|
+
const CONFIGS_FILE = join(homedir(), '.forge', 'data', 'plugin-configs.json');
|
|
27
|
+
|
|
28
|
+
// ─── Load Plugin Definition ──────────────────────────────
|
|
29
|
+
|
|
30
|
+
function loadPluginYaml(filePath: string): PluginDefinition | null {
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
33
|
+
const def = YAML.parse(raw) as PluginDefinition;
|
|
34
|
+
if (!def.id || !def.name || !def.actions) return null;
|
|
35
|
+
// Defaults
|
|
36
|
+
if (!def.config) def.config = {};
|
|
37
|
+
if (!def.params) def.params = {};
|
|
38
|
+
if (!def.icon) def.icon = '🔌';
|
|
39
|
+
if (!def.version) def.version = '0.0.1';
|
|
40
|
+
return def;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Config Storage ──────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function loadConfigs(): Record<string, { config: Record<string, any>; installedAt: string; enabled: boolean }> {
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(CONFIGS_FILE)) {
|
|
51
|
+
return JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8'));
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveConfigs(configs: Record<string, any>): void {
|
|
58
|
+
const dir = dirname(CONFIGS_FILE);
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
writeFileSync(CONFIGS_FILE, JSON.stringify(configs, null, 2));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Public API ──────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** List all available plugins (built-in + user-installed) */
|
|
66
|
+
export function listPlugins(): PluginSource[] {
|
|
67
|
+
const sources: PluginSource[] = [];
|
|
68
|
+
const configs = loadConfigs();
|
|
69
|
+
|
|
70
|
+
// Check if any config entry references this plugin (base install or instance)
|
|
71
|
+
const isInstalledOrHasInstance = (id: string) =>
|
|
72
|
+
Object.entries(configs).some(([key, cfg]) => key === id || (cfg as any).source === id);
|
|
73
|
+
|
|
74
|
+
// Built-in plugins
|
|
75
|
+
if (existsSync(BUILTIN_DIR)) {
|
|
76
|
+
for (const file of readdirSync(BUILTIN_DIR).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
77
|
+
const def = loadPluginYaml(join(BUILTIN_DIR, file));
|
|
78
|
+
if (def) {
|
|
79
|
+
sources.push({
|
|
80
|
+
id: def.id,
|
|
81
|
+
name: def.name,
|
|
82
|
+
icon: def.icon,
|
|
83
|
+
version: def.version,
|
|
84
|
+
author: def.author || 'forge',
|
|
85
|
+
description: def.description || '',
|
|
86
|
+
source: 'builtin',
|
|
87
|
+
installed: isInstalledOrHasInstance(def.id),
|
|
88
|
+
configCount: Object.keys(def.config).length,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// User-installed plugins
|
|
95
|
+
mkdirSync(USER_PLUGINS_DIR, { recursive: true });
|
|
96
|
+
for (const dir of readdirSync(USER_PLUGINS_DIR, { withFileTypes: true })) {
|
|
97
|
+
if (!dir.isDirectory()) continue;
|
|
98
|
+
const yamlPath = join(USER_PLUGINS_DIR, dir.name, 'plugin.yaml');
|
|
99
|
+
const ymlPath = join(USER_PLUGINS_DIR, dir.name, 'plugin.yml');
|
|
100
|
+
const filePath = existsSync(yamlPath) ? yamlPath : existsSync(ymlPath) ? ymlPath : null;
|
|
101
|
+
if (!filePath) continue;
|
|
102
|
+
const def = loadPluginYaml(filePath);
|
|
103
|
+
if (def) {
|
|
104
|
+
// Don't duplicate if also built-in
|
|
105
|
+
if (sources.some(s => s.id === def.id)) continue;
|
|
106
|
+
sources.push({
|
|
107
|
+
id: def.id,
|
|
108
|
+
name: def.name,
|
|
109
|
+
icon: def.icon,
|
|
110
|
+
version: def.version,
|
|
111
|
+
author: def.author || 'local',
|
|
112
|
+
description: def.description || '',
|
|
113
|
+
source: 'local',
|
|
114
|
+
installed: isInstalledOrHasInstance(def.id),
|
|
115
|
+
configCount: Object.keys(def.config).length,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return sources;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get a plugin definition by ID */
|
|
124
|
+
export function getPlugin(id: string): PluginDefinition | null {
|
|
125
|
+
// Check built-in first
|
|
126
|
+
if (existsSync(BUILTIN_DIR)) {
|
|
127
|
+
for (const file of readdirSync(BUILTIN_DIR).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
128
|
+
const def = loadPluginYaml(join(BUILTIN_DIR, file));
|
|
129
|
+
if (def?.id === id) return def;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check user plugins
|
|
134
|
+
const yamlPath = join(USER_PLUGINS_DIR, id, 'plugin.yaml');
|
|
135
|
+
const ymlPath = join(USER_PLUGINS_DIR, id, 'plugin.yml');
|
|
136
|
+
if (existsSync(yamlPath)) return loadPluginYaml(yamlPath);
|
|
137
|
+
if (existsSync(ymlPath)) return loadPluginYaml(ymlPath);
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Get an installed plugin with its config */
|
|
143
|
+
export function getInstalledPlugin(id: string): InstalledPlugin | null {
|
|
144
|
+
const configs = loadConfigs();
|
|
145
|
+
const cfg = configs[id] as any;
|
|
146
|
+
if (!cfg) return null;
|
|
147
|
+
// Resolve definition: use source field for instances, fallback to id
|
|
148
|
+
const sourceId = cfg.source || id;
|
|
149
|
+
const def = getPlugin(sourceId);
|
|
150
|
+
if (!def) return null;
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
definition: def,
|
|
154
|
+
config: cfg.config || {},
|
|
155
|
+
installedAt: cfg.installedAt || new Date().toISOString(),
|
|
156
|
+
enabled: cfg.enabled !== false,
|
|
157
|
+
instanceName: cfg.name,
|
|
158
|
+
source: sourceId !== id ? sourceId : undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** List all installed plugins (including instances) */
|
|
163
|
+
export function listInstalledPlugins(): InstalledPlugin[] {
|
|
164
|
+
const configs = loadConfigs();
|
|
165
|
+
const installed: InstalledPlugin[] = [];
|
|
166
|
+
for (const [id, cfg] of Object.entries(configs)) {
|
|
167
|
+
const sourceId = (cfg as any).source || id;
|
|
168
|
+
const def = getPlugin(sourceId);
|
|
169
|
+
if (def) {
|
|
170
|
+
installed.push({
|
|
171
|
+
id,
|
|
172
|
+
definition: def,
|
|
173
|
+
config: (cfg as any).config || {},
|
|
174
|
+
installedAt: (cfg as any).installedAt || '',
|
|
175
|
+
enabled: (cfg as any).enabled !== false,
|
|
176
|
+
instanceName: (cfg as any).name,
|
|
177
|
+
source: sourceId !== id ? sourceId : undefined,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return installed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Install a plugin or create an instance */
|
|
185
|
+
export function installPlugin(id: string, config: Record<string, any>, opts?: { source?: string; name?: string }): boolean {
|
|
186
|
+
const sourceId = opts?.source || id;
|
|
187
|
+
const def = getPlugin(sourceId);
|
|
188
|
+
if (!def) return false;
|
|
189
|
+
const configs = loadConfigs();
|
|
190
|
+
configs[id] = {
|
|
191
|
+
config,
|
|
192
|
+
installedAt: new Date().toISOString(),
|
|
193
|
+
enabled: true,
|
|
194
|
+
...(opts?.source ? { source: opts.source } : {}),
|
|
195
|
+
...(opts?.name ? { name: opts.name } : {}),
|
|
196
|
+
};
|
|
197
|
+
saveConfigs(configs);
|
|
198
|
+
console.log(`[plugins] Installed: ${opts?.name || def.name} (${id}${opts?.source ? ' ← ' + opts.source : ''})`);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Uninstall a plugin or instance */
|
|
203
|
+
export function uninstallPlugin(id: string): boolean {
|
|
204
|
+
const configs = loadConfigs();
|
|
205
|
+
if (!configs[id]) return false;
|
|
206
|
+
delete configs[id];
|
|
207
|
+
saveConfigs(configs);
|
|
208
|
+
console.log(`[plugins] Uninstalled: ${id}`);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Update plugin config */
|
|
213
|
+
export function updatePluginConfig(id: string, config: Record<string, any>): boolean {
|
|
214
|
+
const configs = loadConfigs();
|
|
215
|
+
if (!configs[id]) return false;
|
|
216
|
+
(configs[id] as any).config = config;
|
|
217
|
+
saveConfigs(configs);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Enable/disable a plugin */
|
|
222
|
+
export function setPluginEnabled(id: string, enabled: boolean): boolean {
|
|
223
|
+
const configs = loadConfigs();
|
|
224
|
+
if (!configs[id]) return false;
|
|
225
|
+
(configs[id] as any).enabled = enabled;
|
|
226
|
+
saveConfigs(configs);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Types — defines the plugin system for pipeline node extensions.
|
|
3
|
+
*
|
|
4
|
+
* A plugin is a reusable, configurable capability that can be used as a
|
|
5
|
+
* pipeline node. Plugins are declarative YAML files with config schema,
|
|
6
|
+
* params schema, and actions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Plugin action execution type */
|
|
10
|
+
export type PluginActionType = 'http' | 'poll' | 'shell' | 'script';
|
|
11
|
+
|
|
12
|
+
/** Schema field definition for config/params */
|
|
13
|
+
export interface PluginFieldSchema {
|
|
14
|
+
type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
|
|
15
|
+
label?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
default?: any;
|
|
19
|
+
options?: string[]; // for select type
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A single action a plugin can perform */
|
|
23
|
+
export interface PluginAction {
|
|
24
|
+
/** Execution type */
|
|
25
|
+
run: PluginActionType;
|
|
26
|
+
|
|
27
|
+
// HTTP action fields
|
|
28
|
+
method?: string; // GET, POST, PUT, DELETE
|
|
29
|
+
url?: string; // URL template (supports {{config.x}}, {{params.x}})
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
body?: string; // body template
|
|
32
|
+
|
|
33
|
+
// Poll action fields (extends HTTP)
|
|
34
|
+
interval?: number; // poll interval in seconds
|
|
35
|
+
until?: string; // JSONPath condition: "$.result != null"
|
|
36
|
+
timeout?: number; // max wait in seconds
|
|
37
|
+
|
|
38
|
+
// Shell action fields
|
|
39
|
+
command?: string; // shell command template
|
|
40
|
+
cwd?: string; // working directory
|
|
41
|
+
|
|
42
|
+
// Script action fields
|
|
43
|
+
script?: string; // path to JS/Python script
|
|
44
|
+
runtime?: 'node' | 'python';
|
|
45
|
+
|
|
46
|
+
// Output extraction
|
|
47
|
+
output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Plugin definition (loaded from plugin.yaml) */
|
|
51
|
+
export interface PluginDefinition {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
icon: string;
|
|
55
|
+
version: string;
|
|
56
|
+
author?: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
|
|
59
|
+
/** Global config — set once when installing the plugin */
|
|
60
|
+
config: Record<string, PluginFieldSchema>;
|
|
61
|
+
|
|
62
|
+
/** Per-use params — set each time the plugin is used in a pipeline node */
|
|
63
|
+
params: Record<string, PluginFieldSchema>;
|
|
64
|
+
|
|
65
|
+
/** Named actions this plugin can perform */
|
|
66
|
+
actions: Record<string, PluginAction>;
|
|
67
|
+
|
|
68
|
+
/** Default action to run if none specified */
|
|
69
|
+
defaultAction?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Installed plugin instance (definition + user config values) */
|
|
73
|
+
export interface InstalledPlugin {
|
|
74
|
+
id: string; // instance ID (e.g., 'jenkins-backend')
|
|
75
|
+
definition: PluginDefinition;
|
|
76
|
+
config: Record<string, any>; // user-provided config values
|
|
77
|
+
installedAt: string;
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
instanceName?: string; // display name (e.g., 'Jenkins Backend')
|
|
80
|
+
source?: string; // source plugin ID if this is an instance (e.g., 'jenkins')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Result of executing a plugin action */
|
|
84
|
+
export interface PluginActionResult {
|
|
85
|
+
ok: boolean;
|
|
86
|
+
output: Record<string, any>;
|
|
87
|
+
rawResponse?: string;
|
|
88
|
+
error?: string;
|
|
89
|
+
duration?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Plugin source for marketplace */
|
|
93
|
+
export interface PluginSource {
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
icon: string;
|
|
97
|
+
version: string;
|
|
98
|
+
author: string;
|
|
99
|
+
description: string;
|
|
100
|
+
source: 'builtin' | 'local' | 'registry';
|
|
101
|
+
installed: boolean;
|
|
102
|
+
configCount: number; // number of config fields — 0 means no config needed
|
|
103
|
+
}
|
package/lib/project-sessions.ts
CHANGED
|
@@ -13,13 +13,18 @@ function getFilePath(): string {
|
|
|
13
13
|
return join(dir, 'project-sessions.json');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
let cache: Record<string, string> | null = null;
|
|
17
|
+
|
|
16
18
|
function loadAll(): Record<string, string> {
|
|
19
|
+
if (cache !== null) return cache;
|
|
17
20
|
const fp = getFilePath();
|
|
18
|
-
if (!existsSync(fp))
|
|
19
|
-
try {
|
|
21
|
+
if (!existsSync(fp)) { cache = {}; return cache; }
|
|
22
|
+
try { cache = JSON.parse(readFileSync(fp, 'utf-8')) as Record<string, string>; } catch { cache = {}; }
|
|
23
|
+
return cache;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
function saveAll(data: Record<string, string>): void {
|
|
27
|
+
cache = data;
|
|
23
28
|
writeFileSync(getFilePath(), JSON.stringify(data, null, 2));
|
|
24
29
|
}
|
|
25
30
|
|
package/lib/session-utils.ts
CHANGED
|
@@ -40,10 +40,14 @@ export function buildResumeFlag(fixedSessionId: string | null, hasExistingSessio
|
|
|
40
40
|
return '';
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const _mcpReady = new Set<string>();
|
|
44
|
+
|
|
45
|
+
/** Get --mcp-config flag for claude-code. Triggers server-side mcp.json creation (once per projectPath). */
|
|
44
46
|
export async function getMcpFlag(projectPath: string): Promise<string> {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
if (!_mcpReady.has(projectPath)) {
|
|
48
|
+
await fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).catch(() => {});
|
|
49
|
+
_mcpReady.add(projectPath);
|
|
50
|
+
}
|
|
47
51
|
return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -64,24 +64,6 @@ function saveTerminalState(data: unknown): void {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/** Get session names that have custom labels (user-renamed) */
|
|
68
|
-
function getRenamedSessions(): Set<string> {
|
|
69
|
-
try {
|
|
70
|
-
const state = loadTerminalState() as any;
|
|
71
|
-
if (!state?.sessionLabels) return new Set();
|
|
72
|
-
// sessionLabels: { "mw-xxx": "My Custom Name", ... }
|
|
73
|
-
// A session is "renamed" if its label differs from default patterns
|
|
74
|
-
const renamed = new Set<string>();
|
|
75
|
-
for (const [sessionName, label] of Object.entries(state.sessionLabels)) {
|
|
76
|
-
if (label && typeof label === 'string') {
|
|
77
|
-
renamed.add(sessionName);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return renamed;
|
|
81
|
-
} catch {
|
|
82
|
-
return new Set();
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
67
|
|
|
86
68
|
// ─── tmux helpers ──────────────────────────────────────────────
|
|
87
69
|
|
|
@@ -179,11 +161,7 @@ function createTmuxSession(cols: number, rows: number): string {
|
|
|
179
161
|
throw e;
|
|
180
162
|
}
|
|
181
163
|
}
|
|
182
|
-
//
|
|
183
|
-
try {
|
|
184
|
-
execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
|
|
185
|
-
execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
|
|
186
|
-
} catch {}
|
|
164
|
+
// Mouse and scrollback are set in attachToTmux (always called after create)
|
|
187
165
|
return name;
|
|
188
166
|
}
|
|
189
167
|
|
|
@@ -211,8 +189,6 @@ function tmuxSessionExists(name: string): boolean {
|
|
|
211
189
|
/** Map from tmux session name → Set of WebSocket clients attached to it */
|
|
212
190
|
const sessionClients = new Map<string, Set<WebSocket>>();
|
|
213
191
|
|
|
214
|
-
/** Map from WebSocket → timestamp when the session was *created* (not attached) by this client */
|
|
215
|
-
const createdAt = new Map<WebSocket, { session: string; time: number }>();
|
|
216
192
|
|
|
217
193
|
function trackAttach(ws: WebSocket, sessionName: string) {
|
|
218
194
|
if (!sessionClients.has(sessionName)) sessionClients.set(sessionName, new Set());
|
|
@@ -232,9 +208,11 @@ function cleanupOrphanedSessions() {
|
|
|
232
208
|
const sessions = listTmuxSessions();
|
|
233
209
|
for (const s of sessions) {
|
|
234
210
|
if (s.attached) continue;
|
|
211
|
+
if (s.name.startsWith(`${SESSION_PREFIX}forge-`)) continue; // workspace agent session — managed by orchestrator
|
|
235
212
|
if (knownSessions.has(s.name)) continue; // saved in terminal state — preserve
|
|
236
213
|
const clients = sessionClients.get(s.name)?.size ?? 0;
|
|
237
214
|
if (clients === 0) {
|
|
215
|
+
console.log(`[terminal] Orphan cleanup: killing "${s.name}"`);
|
|
238
216
|
killTmuxSession(s.name);
|
|
239
217
|
}
|
|
240
218
|
}
|
|
@@ -362,14 +340,10 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
362
340
|
cwd: homedir(),
|
|
363
341
|
env: { ...process.env, TERM: 'xterm-256color' },
|
|
364
342
|
});
|
|
365
|
-
|
|
366
|
-
execSync(`${TMUX} set-option -t ${name} mouse on 2>/dev/null`);
|
|
367
|
-
execSync(`${TMUX} set-option -t ${name} history-limit 50000 2>/dev/null`);
|
|
368
|
-
} catch {}
|
|
343
|
+
// Mouse and scrollback are set in attachToTmux (always called after create)
|
|
369
344
|
} else {
|
|
370
345
|
name = createTmuxSession(cols, rows);
|
|
371
346
|
}
|
|
372
|
-
createdAt.set(ws, { session: name, time: Date.now() });
|
|
373
347
|
attachToTmux(name, cols, rows);
|
|
374
348
|
} catch (e: unknown) {
|
|
375
349
|
const errMsg = e instanceof Error ? e.message : 'unknown error';
|
|
@@ -439,11 +413,9 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
439
413
|
}
|
|
440
414
|
|
|
441
415
|
// Untrack this client
|
|
442
|
-
const disconnectedSession = sessionName;
|
|
443
416
|
if (sessionName) trackDetach(ws, sessionName);
|
|
444
|
-
createdAt.delete(ws);
|
|
445
417
|
|
|
446
|
-
// Orphan cleanup is handled by the periodic cleanupOrphanedSessions() (every
|
|
447
|
-
// which checks sessionClients and
|
|
418
|
+
// Orphan cleanup is handled by the periodic cleanupOrphanedSessions() (every 60s)
|
|
419
|
+
// which checks sessionClients and getKnownSessions() from terminal-state.json
|
|
448
420
|
});
|
|
449
421
|
});
|
|
@@ -423,7 +423,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
423
423
|
let prompt: string;
|
|
424
424
|
switch (reason.type) {
|
|
425
425
|
case 'bus_message':
|
|
426
|
-
prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\
|
|
426
|
+
prompt = `You received new messages from other agents:\n${reason.messages.map(m => m.content).join('\n')}\n\nAct on these messages. Be concise — don't repeat the message content back. Use tools to inspect files or git history if you need more details.`;
|
|
427
427
|
break;
|
|
428
428
|
case 'upstream_changed':
|
|
429
429
|
prompt = `Your upstream dependency (agent ${reason.agentId}) has produced new output: ${reason.files.join(', ')}.\n\nRe-analyze and update your work based on the new upstream output.`;
|