@aion0/forge 0.5.43 → 0.5.44
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/RELEASE_NOTES.md +14 -5
- package/components/Dashboard.tsx +14 -0
- package/intellij-plugin/README.md +53 -0
- package/intellij-plugin/build.gradle.kts +60 -0
- package/intellij-plugin/gradle/gradle-daemon-jvm.properties +12 -0
- package/intellij-plugin/gradle.properties +9 -0
- package/intellij-plugin/publish.sh +78 -0
- package/intellij-plugin/settings.gradle.kts +7 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LoginAction.kt +49 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LogoutAction.kt +18 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/OpenWebUIAction.kt +13 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/SwitchConnectionAction.kt +26 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/api/ForgeClient.kt +115 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/auth/Auth.kt +31 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/connection/ConnectionManager.kt +95 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/settings/ForgeConfigurable.kt +81 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/DocsView.kt +99 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeStatusBarWidgetFactory.kt +94 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeToolWindowFactory.kt +27 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeTreeView.kt +176 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/Helpers.kt +48 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/PipelinesView.kt +226 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TerminalsView.kt +309 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TreeNodeData.kt +33 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/WorkspacesView.kt +166 -0
- package/intellij-plugin/src/main/resources/META-INF/plugin.xml +88 -0
- package/intellij-plugin/src/main/resources/icons/forge.svg +3 -0
- package/lib/agents/index.ts +1 -1
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/13-ide-plugins.md +90 -0
- package/lib/help-docs/CLAUDE.md +3 -0
- package/package.json +1 -1
- package/vscode-extension/.vscodeignore +11 -0
- package/vscode-extension/README.md +48 -0
- package/vscode-extension/media/icon.png +0 -0
- package/vscode-extension/media/icon.svg +3 -0
- package/vscode-extension/package-lock.json +4046 -0
- package/vscode-extension/package.json +514 -0
- package/vscode-extension/publish.sh +49 -0
- package/vscode-extension/src/api/client.ts +217 -0
- package/vscode-extension/src/auth/auth.ts +32 -0
- package/vscode-extension/src/commands/auth.ts +44 -0
- package/vscode-extension/src/commands/connection.ts +113 -0
- package/vscode-extension/src/commands/docs.ts +40 -0
- package/vscode-extension/src/commands/pipeline.ts +103 -0
- package/vscode-extension/src/commands/server.ts +50 -0
- package/vscode-extension/src/commands/smith.ts +112 -0
- package/vscode-extension/src/commands/task.ts +43 -0
- package/vscode-extension/src/commands/terminal.ts +279 -0
- package/vscode-extension/src/commands/workspace.ts +138 -0
- package/vscode-extension/src/connection/manager.ts +80 -0
- package/vscode-extension/src/docs/fs-provider.ts +94 -0
- package/vscode-extension/src/docs/result-provider.ts +33 -0
- package/vscode-extension/src/docs/transport.ts +22 -0
- package/vscode-extension/src/extension.ts +314 -0
- package/vscode-extension/src/statusbar.ts +70 -0
- package/vscode-extension/src/terminal/pseudoterm.ts +123 -0
- package/vscode-extension/src/views/docs.ts +145 -0
- package/vscode-extension/src/views/pipelines.ts +222 -0
- package/vscode-extension/src/views/terminals.ts +91 -0
- package/vscode-extension/src/views/workspaces.ts +243 -0
- package/vscode-extension/tsconfig.json +16 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { ForgeClient } from '../api/client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `forge-docs://` URIs map to forge's HTTP API for read/write — used in
|
|
6
|
+
* remote-forge mode where the doc files live on a different machine.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/docs?root=<N>&file=<path> (text)
|
|
9
|
+
* GET /api/docs?root=<N>&image=<path> (binary)
|
|
10
|
+
* PUT /api/docs (write)
|
|
11
|
+
*
|
|
12
|
+
* VSCode treats files opened via this scheme as real files: the editor tab
|
|
13
|
+
* shows the actual filename, Cmd+S saves through writeFile, all editor
|
|
14
|
+
* features work.
|
|
15
|
+
*/
|
|
16
|
+
export class ForgeDocsFs implements vscode.FileSystemProvider {
|
|
17
|
+
private _emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
|
|
18
|
+
onDidChangeFile = this._emitter.event;
|
|
19
|
+
|
|
20
|
+
constructor(private client: ForgeClient) {}
|
|
21
|
+
|
|
22
|
+
watch(): vscode.Disposable {
|
|
23
|
+
return new vscode.Disposable(() => {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
|
|
27
|
+
const { path } = parseDocUri(uri);
|
|
28
|
+
if (!path) return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 };
|
|
29
|
+
return { type: vscode.FileType.File, ctime: 0, mtime: Date.now(), size: 0 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async readDirectory(): Promise<[string, vscode.FileType][]> { return []; }
|
|
33
|
+
createDirectory(): void { throw vscode.FileSystemError.NoPermissions(); }
|
|
34
|
+
|
|
35
|
+
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
|
|
36
|
+
const { rootIdx, path } = parseDocUri(uri);
|
|
37
|
+
if (isImagePath(path)) {
|
|
38
|
+
const bytes = await fetchImageBytes(this.client, rootIdx, path);
|
|
39
|
+
if (!bytes) throw vscode.FileSystemError.FileNotFound(uri);
|
|
40
|
+
return bytes;
|
|
41
|
+
}
|
|
42
|
+
const r = await this.client.request<any>(`/api/docs?root=${rootIdx}&file=${encodeURIComponent(path)}`);
|
|
43
|
+
if (!r.ok) throw vscode.FileSystemError.FileNotFound(uri);
|
|
44
|
+
if (r.data?.tooLarge) throw vscode.FileSystemError.NoPermissions('File too large');
|
|
45
|
+
if (r.data?.binary) throw vscode.FileSystemError.NoPermissions(`Binary ${r.data.fileType} file`);
|
|
46
|
+
return Buffer.from(r.data?.content || '', 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async writeFile(uri: vscode.Uri, content: Uint8Array): Promise<void> {
|
|
50
|
+
const { rootIdx, path } = parseDocUri(uri);
|
|
51
|
+
const text = Buffer.from(content).toString('utf-8');
|
|
52
|
+
const r = await this.client.request('/api/docs', {
|
|
53
|
+
method: 'PUT',
|
|
54
|
+
body: JSON.stringify({ root: rootIdx, file: path, content: text }),
|
|
55
|
+
});
|
|
56
|
+
if (!r.ok) {
|
|
57
|
+
vscode.window.showErrorMessage(`Forge: failed to save ${path} — ${r.error}`);
|
|
58
|
+
throw vscode.FileSystemError.NoPermissions(`Save failed: ${r.error}`);
|
|
59
|
+
}
|
|
60
|
+
this._emitter.fire([{ type: vscode.FileChangeType.Changed, uri }]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
delete(): void { throw vscode.FileSystemError.NoPermissions(); }
|
|
64
|
+
rename(): void { throw vscode.FileSystemError.NoPermissions(); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildDocUri(rootIdx: number, path: string): vscode.Uri {
|
|
68
|
+
return vscode.Uri.parse(`forge-docs:/${rootIdx}/${path.split('/').map(encodeURIComponent).join('/')}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseDocUri(uri: vscode.Uri): { rootIdx: number; path: string } {
|
|
72
|
+
const segments = uri.path.split('/').filter(Boolean);
|
|
73
|
+
const rootIdx = parseInt(segments[0] || '0', 10) || 0;
|
|
74
|
+
const path = segments.slice(1).map(decodeURIComponent).join('/');
|
|
75
|
+
return { rootIdx, path };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isImagePath(path: string): boolean {
|
|
79
|
+
const ext = path.toLowerCase().split('.').pop() || '';
|
|
80
|
+
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif'].includes(ext);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchImageBytes(client: ForgeClient, rootIdx: number, path: string): Promise<Uint8Array | null> {
|
|
84
|
+
const token = await client.getToken();
|
|
85
|
+
const headers: Record<string, string> = {};
|
|
86
|
+
if (token) headers['X-Forge-Token'] = token;
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${client.baseUrlPublic}/api/docs?root=${rootIdx}&image=${encodeURIComponent(path)}`, { headers });
|
|
89
|
+
if (!res.ok) return null;
|
|
90
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
|
|
3
|
+
/** Read-only TextDocumentContentProvider for `forge-result://` URIs.
|
|
4
|
+
*
|
|
5
|
+
* Used for ephemeral detail views (pipeline node result, error dumps, etc).
|
|
6
|
+
* Each click on the same logical thing reuses the same URI → preview-mode
|
|
7
|
+
* tabs replace each other, no untitled buffer, no save prompt on close.
|
|
8
|
+
*
|
|
9
|
+
* URI shape: `forge-result:/<key>.md` — `<key>` should encode whatever makes
|
|
10
|
+
* the document unique (run id + node name, etc). The `.md` suffix lets
|
|
11
|
+
* VSCode auto-pick the markdown language.
|
|
12
|
+
*/
|
|
13
|
+
export class ForgeResultProvider implements vscode.TextDocumentContentProvider {
|
|
14
|
+
private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
|
|
15
|
+
onDidChange = this._onDidChange.event;
|
|
16
|
+
private contents = new Map<string, string>();
|
|
17
|
+
|
|
18
|
+
setContent(uri: vscode.Uri, content: string): void {
|
|
19
|
+
this.contents.set(uri.toString(), content);
|
|
20
|
+
this._onDidChange.fire(uri);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
provideTextDocumentContent(uri: vscode.Uri): string {
|
|
24
|
+
return this.contents.get(uri.toString()) || '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SCHEME = 'forge-result';
|
|
29
|
+
|
|
30
|
+
export function buildResultUri(key: string): vscode.Uri {
|
|
31
|
+
// Encode key, ensure .md suffix for markdown highlighting.
|
|
32
|
+
return vscode.Uri.parse(`${SCHEME}:/${encodeURIComponent(key)}.md`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
|
|
3
|
+
export type DocsTransport = 'local' | 'http';
|
|
4
|
+
|
|
5
|
+
/** Detect whether forge is reachable as the local filesystem (we can use
|
|
6
|
+
* `vscode.Uri.file(absPath)`) or only over the wire (we must go through
|
|
7
|
+
* the forge-docs:// FileSystemProvider). */
|
|
8
|
+
export function detectDocsTransport(): DocsTransport {
|
|
9
|
+
const cfg = vscode.workspace.getConfiguration('forge');
|
|
10
|
+
const setting = cfg.get<string>('docs.transport', 'auto');
|
|
11
|
+
if (setting === 'local') return 'local';
|
|
12
|
+
if (setting === 'http') return 'http';
|
|
13
|
+
// Auto: localhost / 127.0.0.1 / [::1] → local, else http
|
|
14
|
+
const serverUrl = cfg.get<string>('serverUrl', 'http://localhost:8403');
|
|
15
|
+
try {
|
|
16
|
+
const u = new URL(serverUrl);
|
|
17
|
+
const local = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname);
|
|
18
|
+
return local ? 'local' : 'http';
|
|
19
|
+
} catch {
|
|
20
|
+
return 'http';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { Auth } from './auth/auth';
|
|
3
|
+
import { ConnectionManager } from './connection/manager';
|
|
4
|
+
import { ForgeClient } from './api/client';
|
|
5
|
+
import { switchConnectionCommand, addConnectionCommand, removeConnectionCommand, editConnectionsCommand } from './commands/connection';
|
|
6
|
+
import { WorkspacesProvider } from './views/workspaces';
|
|
7
|
+
import { TerminalsProvider } from './views/terminals';
|
|
8
|
+
import { PipelinesProvider } from './views/pipelines';
|
|
9
|
+
import { DocsProvider } from './views/docs';
|
|
10
|
+
import { StatusBar } from './statusbar';
|
|
11
|
+
import { loginCommand, logoutCommand } from './commands/auth';
|
|
12
|
+
import { openTerminalCommand, attachTerminalCommand, openSessionCommand, sendSelectionCommand } from './commands/terminal';
|
|
13
|
+
import { startServerCommand, stopServerCommand, openWebUICommand } from './commands/server';
|
|
14
|
+
import { newTaskCommand } from './commands/task';
|
|
15
|
+
import {
|
|
16
|
+
openWorkspaceForFolderCommand, openWorkspaceCommand,
|
|
17
|
+
startDaemonCommand, stopDaemonCommand, restartDaemonCommand,
|
|
18
|
+
} from './commands/workspace';
|
|
19
|
+
import {
|
|
20
|
+
smithOpenTerminalCommand, smithPauseCommand, smithResumeCommand,
|
|
21
|
+
smithMarkDoneCommand, smithMarkFailedCommand, smithMarkIdleCommand,
|
|
22
|
+
smithRetryCommand, smithSendMessageCommand, registerSmithTerminalCleanup,
|
|
23
|
+
} from './commands/smith';
|
|
24
|
+
import { openDocCommand, openDocsTerminalCommand } from './commands/docs';
|
|
25
|
+
import { ForgeDocsFs } from './docs/fs-provider';
|
|
26
|
+
import { ForgeResultProvider, SCHEME as RESULT_SCHEME, buildResultUri } from './docs/result-provider';
|
|
27
|
+
import { addPipelineCommand, triggerPipelineCommand, togglePipelineCommand, removePipelineCommand } from './commands/pipeline';
|
|
28
|
+
|
|
29
|
+
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
|
30
|
+
const auth = new Auth(context.secrets);
|
|
31
|
+
const conn = new ConnectionManager();
|
|
32
|
+
conn.watchConfig(context);
|
|
33
|
+
const client = new ForgeClient(auth, conn);
|
|
34
|
+
|
|
35
|
+
// One-time migration of legacy single-token storage to per-connection.
|
|
36
|
+
await auth.migrateLegacy(conn.active().name);
|
|
37
|
+
|
|
38
|
+
// Smith terminal cache — reuse VSCode terminal pane on repeated click
|
|
39
|
+
registerSmithTerminalCleanup(context);
|
|
40
|
+
|
|
41
|
+
// Tree views
|
|
42
|
+
const wsProvider = new WorkspacesProvider(client);
|
|
43
|
+
const termProvider = new TerminalsProvider(client);
|
|
44
|
+
const pipelineProvider = new PipelinesProvider(client);
|
|
45
|
+
const docsProvider = new DocsProvider(client);
|
|
46
|
+
context.subscriptions.push(
|
|
47
|
+
vscode.window.registerTreeDataProvider('forge.workspaces', wsProvider),
|
|
48
|
+
vscode.window.registerTreeDataProvider('forge.terminals', termProvider),
|
|
49
|
+
vscode.window.registerTreeDataProvider('forge.pipelines', pipelineProvider),
|
|
50
|
+
vscode.window.registerTreeDataProvider('forge.docs', docsProvider),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const refreshAll = () => {
|
|
54
|
+
wsProvider.refresh();
|
|
55
|
+
termProvider.refresh();
|
|
56
|
+
pipelineProvider.refresh();
|
|
57
|
+
docsProvider.refresh();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Status bar
|
|
61
|
+
const status = new StatusBar(client, auth);
|
|
62
|
+
context.subscriptions.push(status);
|
|
63
|
+
const interval = vscode.workspace.getConfiguration('forge').get<number>('refreshInterval', 5);
|
|
64
|
+
status.startPolling(interval);
|
|
65
|
+
|
|
66
|
+
// Auto-refresh tree views on the same interval
|
|
67
|
+
const treeTimer = setInterval(refreshAll, Math.max(interval, 2) * 1000);
|
|
68
|
+
context.subscriptions.push({ dispose: () => clearInterval(treeTimer) });
|
|
69
|
+
|
|
70
|
+
// Commands
|
|
71
|
+
context.subscriptions.push(
|
|
72
|
+
vscode.commands.registerCommand('forge.login', () => loginCommand(client)),
|
|
73
|
+
vscode.commands.registerCommand('forge.logout', () => logoutCommand(client)),
|
|
74
|
+
vscode.commands.registerCommand('forge.startServer', () => startServerCommand(client)),
|
|
75
|
+
vscode.commands.registerCommand('forge.stopServer', () => stopServerCommand()),
|
|
76
|
+
vscode.commands.registerCommand('forge.openWebUI', () => openWebUICommand(client)),
|
|
77
|
+
vscode.commands.registerCommand('forge.openTerminal', () => openTerminalCommand(client)),
|
|
78
|
+
vscode.commands.registerCommand('forge.attachTerminal',(arg) => attachTerminalCommand(client, arg)),
|
|
79
|
+
vscode.commands.registerCommand('forge.openSession', (arg) => openSessionCommand(client, arg)),
|
|
80
|
+
vscode.commands.registerCommand('forge.sendSelection', () => sendSelectionCommand(client)),
|
|
81
|
+
vscode.commands.registerCommand('forge.newTask', () => newTaskCommand(client)),
|
|
82
|
+
vscode.commands.registerCommand('forge.refresh', () => { refreshAll(); status.update(); }),
|
|
83
|
+
|
|
84
|
+
// Connection management
|
|
85
|
+
vscode.commands.registerCommand('forge.switchConnection', () => switchConnectionCommand(conn)),
|
|
86
|
+
vscode.commands.registerCommand('forge.addConnection', () => addConnectionCommand(conn)),
|
|
87
|
+
vscode.commands.registerCommand('forge.removeConnection', () => removeConnectionCommand(conn)),
|
|
88
|
+
vscode.commands.registerCommand('forge.editConnections', () => editConnectionsCommand()),
|
|
89
|
+
|
|
90
|
+
// Workspace bootstrap + daemon control
|
|
91
|
+
vscode.commands.registerCommand('forge.openWorkspaceForFolder', () => openWorkspaceForFolderCommand(client)),
|
|
92
|
+
vscode.commands.registerCommand('forge.openWorkspace', () => openWorkspaceCommand(client)),
|
|
93
|
+
vscode.commands.registerCommand('forge.startDaemon', (arg) => startDaemonCommand(client, arg?.meta || arg)),
|
|
94
|
+
vscode.commands.registerCommand('forge.stopDaemon', (arg) => stopDaemonCommand(client, arg?.meta || arg)),
|
|
95
|
+
vscode.commands.registerCommand('forge.restartDaemon',(arg) => restartDaemonCommand(client, arg?.meta || arg)),
|
|
96
|
+
|
|
97
|
+
// Smith actions (right-click menu + click)
|
|
98
|
+
vscode.commands.registerCommand('forge.smithOpenTerminal', (arg) => smithOpenTerminalCommand(client, arg?.meta || arg)),
|
|
99
|
+
vscode.commands.registerCommand('forge.smithPause', (arg) => smithPauseCommand(client, arg?.meta || arg)),
|
|
100
|
+
vscode.commands.registerCommand('forge.smithResume', (arg) => smithResumeCommand(client, arg?.meta || arg)),
|
|
101
|
+
vscode.commands.registerCommand('forge.smithMarkDone', (arg) => smithMarkDoneCommand(client, arg?.meta || arg)),
|
|
102
|
+
vscode.commands.registerCommand('forge.smithMarkFailed', (arg) => smithMarkFailedCommand(client, arg?.meta || arg)),
|
|
103
|
+
vscode.commands.registerCommand('forge.smithMarkIdle', (arg) => smithMarkIdleCommand(client, arg?.meta || arg)),
|
|
104
|
+
vscode.commands.registerCommand('forge.smithRetry', (arg) => smithRetryCommand(client, arg?.meta || arg)),
|
|
105
|
+
vscode.commands.registerCommand('forge.smithSendMessage', (arg) => smithSendMessageCommand(client, arg?.meta || arg)),
|
|
106
|
+
|
|
107
|
+
// Docs
|
|
108
|
+
vscode.commands.registerCommand('forge.openDoc', (arg) => openDocCommand(client, arg?.meta || arg)),
|
|
109
|
+
vscode.commands.registerCommand('forge.openDocsTerminal', (arg) => openDocsTerminalCommand(client, arg?.meta || arg)),
|
|
110
|
+
|
|
111
|
+
// Pipelines (project-bound)
|
|
112
|
+
vscode.commands.registerCommand('forge.addPipeline', (arg) => addPipelineCommand(client, arg?.meta || arg)),
|
|
113
|
+
vscode.commands.registerCommand('forge.triggerPipeline', (arg) => triggerPipelineCommand(client, arg?.meta || arg)),
|
|
114
|
+
vscode.commands.registerCommand('forge.togglePipeline', (arg) => togglePipelineCommand(client, arg?.meta || arg)),
|
|
115
|
+
vscode.commands.registerCommand('forge.removePipeline', (arg) => removePipelineCommand(client, arg?.meta || arg)),
|
|
116
|
+
vscode.commands.registerCommand('forge.showPipelineNodeError', async (arg) => {
|
|
117
|
+
if (!arg?.error) return;
|
|
118
|
+
const uri = buildResultUri(`error/${arg.nodeName}`);
|
|
119
|
+
resultProvider.setContent(uri, `# Pipeline node \`${arg.nodeName}\` failed\n\n\`\`\`\n${arg.error}\n\`\`\`\n`);
|
|
120
|
+
await vscode.commands.executeCommand('vscode.open', uri);
|
|
121
|
+
}),
|
|
122
|
+
|
|
123
|
+
// Pipeline node detail view. Combines whatever we know — node status,
|
|
124
|
+
// duration, error, outputs — plus the underlying forge task (if its ID is
|
|
125
|
+
// resolvable). A pipeline node's taskId may not always map to /api/tasks
|
|
126
|
+
// (different ID spaces depending on workflow type), so a 404 is silently
|
|
127
|
+
// skipped rather than rendered as an error.
|
|
128
|
+
vscode.commands.registerCommand('forge.showPipelineNodeResult', async (arg) => {
|
|
129
|
+
if (!arg) return;
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
lines.push(`# Pipeline node: \`${arg.nodeName}\``);
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push(`**Status:** ${arg.status || 'unknown'}`);
|
|
134
|
+
if (arg.taskId) lines.push(`**Task ID:** \`${arg.taskId}\``);
|
|
135
|
+
if (arg.startedAt) {
|
|
136
|
+
const start = new Date(arg.startedAt).toLocaleString();
|
|
137
|
+
const end = arg.completedAt ? new Date(arg.completedAt).toLocaleString() : '(running)';
|
|
138
|
+
const dur = arg.completedAt
|
|
139
|
+
? `${Math.round((new Date(arg.completedAt).getTime() - new Date(arg.startedAt).getTime()) / 1000)}s`
|
|
140
|
+
: '';
|
|
141
|
+
lines.push(`**Started:** ${start}`);
|
|
142
|
+
lines.push(`**Completed:** ${end}${dur ? ` (${dur})` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push('');
|
|
145
|
+
|
|
146
|
+
// Pull the underlying task if we can — silently skip on 404.
|
|
147
|
+
let task: any = null;
|
|
148
|
+
if (arg.taskId) {
|
|
149
|
+
const r = await client.getTask(arg.taskId);
|
|
150
|
+
if (r.ok && r.data) task = r.data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (task) {
|
|
154
|
+
if (task.prompt) {
|
|
155
|
+
lines.push('## Prompt');
|
|
156
|
+
lines.push('```');
|
|
157
|
+
lines.push(task.prompt);
|
|
158
|
+
lines.push('```');
|
|
159
|
+
lines.push('');
|
|
160
|
+
}
|
|
161
|
+
if (task.resultSummary) {
|
|
162
|
+
lines.push('## Result');
|
|
163
|
+
lines.push(task.resultSummary);
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
if (task.gitDiff) {
|
|
167
|
+
lines.push('## Git Diff');
|
|
168
|
+
lines.push('```diff');
|
|
169
|
+
lines.push(task.gitDiff);
|
|
170
|
+
lines.push('```');
|
|
171
|
+
lines.push('');
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(task.log) && task.log.length > 0) {
|
|
174
|
+
lines.push('## Log (last 20 entries)');
|
|
175
|
+
for (const e of task.log.slice(-20)) {
|
|
176
|
+
const time = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '';
|
|
177
|
+
const tag = e.subtype || e.type || 'log';
|
|
178
|
+
const content = (e.content || '').toString();
|
|
179
|
+
lines.push(`- \`[${time}] [${tag}]\` ${content.slice(0, 500).replace(/\n/g, ' ')}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push('');
|
|
182
|
+
}
|
|
183
|
+
if (task.costUSD) {
|
|
184
|
+
lines.push(`**Cost:** $${task.costUSD.toFixed?.(4) ?? task.costUSD}`);
|
|
185
|
+
lines.push('');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (arg.error) {
|
|
190
|
+
lines.push('## Error');
|
|
191
|
+
lines.push('```');
|
|
192
|
+
lines.push(String(arg.error));
|
|
193
|
+
lines.push('```');
|
|
194
|
+
lines.push('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (arg.outputs && Object.keys(arg.outputs).length > 0) {
|
|
198
|
+
lines.push('## Outputs');
|
|
199
|
+
for (const [k, v] of Object.entries(arg.outputs)) {
|
|
200
|
+
lines.push(`### ${k}`);
|
|
201
|
+
lines.push('```');
|
|
202
|
+
lines.push(typeof v === 'string' ? v : JSON.stringify(v, null, 2));
|
|
203
|
+
lines.push('```');
|
|
204
|
+
lines.push('');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// If we got nothing useful at all (pending node, or node didn't track a
|
|
209
|
+
// task), say so explicitly instead of leaving a near-empty doc.
|
|
210
|
+
if (!task && !arg.error && !(arg.outputs && Object.keys(arg.outputs).length > 0)) {
|
|
211
|
+
lines.push('---');
|
|
212
|
+
lines.push('');
|
|
213
|
+
if (arg.status === 'pending') {
|
|
214
|
+
lines.push('_Node has not run yet — re-open after the pipeline reaches it._');
|
|
215
|
+
} else if (arg.taskId) {
|
|
216
|
+
lines.push(`_The associated task \`${arg.taskId}\` is no longer in the forge task store. The node may have run via the workspace daemon (which uses a separate ID space) or the task was cleaned up._`);
|
|
217
|
+
} else {
|
|
218
|
+
lines.push('_This node did not produce any task output, error, or named outputs._');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Use a stable URI per (run, node) so re-clicking the same node reuses
|
|
223
|
+
// the tab; clicking a different node opens in preview mode and replaces
|
|
224
|
+
// the previous unpinned preview tab.
|
|
225
|
+
const key = `node/${arg.runId || 'std'}/${arg.nodeName}`;
|
|
226
|
+
const uri = buildResultUri(key);
|
|
227
|
+
resultProvider.setContent(uri, lines.join('\n'));
|
|
228
|
+
await vscode.commands.executeCommand('vscode.open', uri);
|
|
229
|
+
}),
|
|
230
|
+
|
|
231
|
+
// Quick-open in forge web UI. Forge supports `?view=<mode>` to deep-link
|
|
232
|
+
// into a section — the calling tree item passes a `view` hint via meta.
|
|
233
|
+
vscode.commands.registerCommand('forge.openItemInWebUI', (arg) => {
|
|
234
|
+
const meta = arg?.meta || arg || {};
|
|
235
|
+
const view = meta.view || meta.webUiView;
|
|
236
|
+
const base = client.baseUrlPublic;
|
|
237
|
+
const url = view ? `${base}/?view=${encodeURIComponent(view)}` : base;
|
|
238
|
+
vscode.env.openExternal(vscode.Uri.parse(url));
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Always register the forge-docs:// FS provider — used when transport is
|
|
243
|
+
// 'http' (auto-selected for remote forges, or forced via settings).
|
|
244
|
+
context.subscriptions.push(
|
|
245
|
+
vscode.workspace.registerFileSystemProvider('forge-docs', new ForgeDocsFs(client), { isCaseSensitive: true }),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Read-only virtual provider for ephemeral detail views (pipeline node
|
|
249
|
+
// results, error dumps). Same URI = same tab, no save prompt on close.
|
|
250
|
+
const resultProvider = new ForgeResultProvider();
|
|
251
|
+
context.subscriptions.push(
|
|
252
|
+
vscode.workspace.registerTextDocumentContentProvider(RESULT_SCHEME, resultProvider),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Auto-start option
|
|
256
|
+
if (vscode.workspace.getConfiguration('forge').get<boolean>('autoStart', false)) {
|
|
257
|
+
if (!(await client.ping())) {
|
|
258
|
+
void startServerCommand(client);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// First-run UX: if forge is reachable but we have no token, prompt for login
|
|
263
|
+
// immediately. Otherwise users have to dig through the command palette.
|
|
264
|
+
void (async () => {
|
|
265
|
+
// Wait briefly so a freshly auto-started server has a chance to come up.
|
|
266
|
+
await new Promise(r => setTimeout(r, 800));
|
|
267
|
+
if (!(await client.ping())) return; // server offline → status bar already says so
|
|
268
|
+
if (await auth.getToken(client.activeName)) {
|
|
269
|
+
// Token exists, but verify it still works (server may have been restarted)
|
|
270
|
+
const probe = await client.listProjects();
|
|
271
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
272
|
+
await client.logout();
|
|
273
|
+
} else {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// No token (or stale token cleared) → ask now
|
|
278
|
+
void loginCommand(client);
|
|
279
|
+
})();
|
|
280
|
+
|
|
281
|
+
// Notifications: poll unread bell events
|
|
282
|
+
if (vscode.workspace.getConfiguration('forge').get<boolean>('notifications.enabled', true)) {
|
|
283
|
+
const seen = new Set<string>();
|
|
284
|
+
const notifyTimer = setInterval(async () => {
|
|
285
|
+
const r = await client.getNotifications();
|
|
286
|
+
const list: any[] = r.ok && r.data?.notifications ? r.data.notifications : [];
|
|
287
|
+
for (const n of list) {
|
|
288
|
+
if (n.read || seen.has(n.id)) continue;
|
|
289
|
+
seen.add(n.id);
|
|
290
|
+
vscode.window.showInformationMessage(`Forge: ${n.title || n.message || 'notification'}`);
|
|
291
|
+
}
|
|
292
|
+
}, 30_000);
|
|
293
|
+
context.subscriptions.push({ dispose: () => clearInterval(notifyTimer) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Re-read settings when the user changes them
|
|
297
|
+
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => {
|
|
298
|
+
if (e.affectsConfiguration('forge.refreshInterval')) {
|
|
299
|
+
const i = vscode.workspace.getConfiguration('forge').get<number>('refreshInterval', 5);
|
|
300
|
+
status.startPolling(i);
|
|
301
|
+
}
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
// When the active connection switches (or its URLs change), reset everything
|
|
305
|
+
// that depends on it: trees, status, and any cached state.
|
|
306
|
+
context.subscriptions.push(conn.onDidChange(() => {
|
|
307
|
+
refreshAll();
|
|
308
|
+
void status.update();
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function deactivate(): void {
|
|
313
|
+
// disposables are released by VSCode automatically
|
|
314
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { ForgeClient } from './api/client';
|
|
3
|
+
import { Auth } from './auth/auth';
|
|
4
|
+
|
|
5
|
+
type Status = 'connected' | 'auth' | 'offline';
|
|
6
|
+
|
|
7
|
+
export class StatusBar {
|
|
8
|
+
private item: vscode.StatusBarItem;
|
|
9
|
+
private timer: NodeJS.Timeout | null = null;
|
|
10
|
+
|
|
11
|
+
constructor(private client: ForgeClient, private auth: Auth) {
|
|
12
|
+
this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
|
13
|
+
this.item.command = 'forge.openWebUI';
|
|
14
|
+
this.item.show();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async update(): Promise<void> {
|
|
18
|
+
const state = await this.detect();
|
|
19
|
+
const connName = this.client.activeName;
|
|
20
|
+
switch (state) {
|
|
21
|
+
case 'connected':
|
|
22
|
+
this.item.text = `$(zap) Forge: ${connName}`;
|
|
23
|
+
this.item.tooltip = `Forge connected (${connName}) — click to switch connection`;
|
|
24
|
+
this.item.command = 'forge.switchConnection';
|
|
25
|
+
this.item.backgroundColor = undefined;
|
|
26
|
+
break;
|
|
27
|
+
case 'auth':
|
|
28
|
+
this.item.text = `$(key) Forge: ${connName}`;
|
|
29
|
+
this.item.tooltip = `Forge (${connName}): login required`;
|
|
30
|
+
this.item.command = 'forge.login';
|
|
31
|
+
this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
|
32
|
+
break;
|
|
33
|
+
case 'offline':
|
|
34
|
+
this.item.text = `$(circle-slash) Forge: ${connName}`;
|
|
35
|
+
this.item.tooltip = `Forge (${connName}) unreachable — click to switch connection`;
|
|
36
|
+
this.item.command = 'forge.switchConnection';
|
|
37
|
+
this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async detect(): Promise<Status> {
|
|
43
|
+
const reachable = await this.client.ping();
|
|
44
|
+
if (!reachable) return 'offline';
|
|
45
|
+
const token = await this.auth.getToken(this.client.activeName);
|
|
46
|
+
if (!token) return 'auth';
|
|
47
|
+
// Quick auth probe: fetch any authed endpoint
|
|
48
|
+
const r = await this.client.listProjects();
|
|
49
|
+
if (r.status === 401 || r.status === 403) return 'auth';
|
|
50
|
+
return 'connected';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
startPolling(seconds: number): void {
|
|
54
|
+
this.stopPolling();
|
|
55
|
+
void this.update();
|
|
56
|
+
this.timer = setInterval(() => void this.update(), seconds * 1000);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
stopPolling(): void {
|
|
60
|
+
if (this.timer) {
|
|
61
|
+
clearInterval(this.timer);
|
|
62
|
+
this.timer = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
dispose(): void {
|
|
67
|
+
this.stopPolling();
|
|
68
|
+
this.item.dispose();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
export interface ForgePtyOptions {
|
|
5
|
+
url: string; // ws://host:port
|
|
6
|
+
attach?: string; // existing tmux session name to attach to
|
|
7
|
+
cwd?: string; // for newly created sessions
|
|
8
|
+
/** Command auto-typed after the new tmux session is connected (e.g. `claude --resume <id>`).
|
|
9
|
+
* Ignored on attach. */
|
|
10
|
+
launchCommand?: string;
|
|
11
|
+
cols?: number;
|
|
12
|
+
rows?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* VSCode Pseudoterminal that bridges to Forge's terminal WebSocket.
|
|
17
|
+
*
|
|
18
|
+
* Protocol (from lib/terminal-standalone.ts):
|
|
19
|
+
* client → server: { type: 'attach' | 'create', sessionName?, cols, rows, cwd? }
|
|
20
|
+
* { type: 'input', data }
|
|
21
|
+
* { type: 'resize', cols, rows }
|
|
22
|
+
* server → client: { type: 'output', data }
|
|
23
|
+
* { type: 'connected', sessionName }
|
|
24
|
+
* { type: 'error', message }
|
|
25
|
+
*/
|
|
26
|
+
export class ForgePty implements vscode.Pseudoterminal {
|
|
27
|
+
private writeEmitter = new vscode.EventEmitter<string>();
|
|
28
|
+
private closeEmitter = new vscode.EventEmitter<number>();
|
|
29
|
+
private ws: WebSocket | null = null;
|
|
30
|
+
private cols = 80;
|
|
31
|
+
private rows = 24;
|
|
32
|
+
private opened = false;
|
|
33
|
+
|
|
34
|
+
onDidWrite = this.writeEmitter.event;
|
|
35
|
+
onDidClose = this.closeEmitter.event;
|
|
36
|
+
|
|
37
|
+
constructor(private opts: ForgePtyOptions) {
|
|
38
|
+
this.cols = opts.cols || 80;
|
|
39
|
+
this.rows = opts.rows || 24;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
open(initialDimensions: vscode.TerminalDimensions | undefined): void {
|
|
43
|
+
if (initialDimensions) {
|
|
44
|
+
this.cols = initialDimensions.columns;
|
|
45
|
+
this.rows = initialDimensions.rows;
|
|
46
|
+
}
|
|
47
|
+
this.opened = true;
|
|
48
|
+
this.connect();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
close(): void {
|
|
52
|
+
this.opened = false;
|
|
53
|
+
if (this.ws) {
|
|
54
|
+
try { this.ws.close(); } catch {}
|
|
55
|
+
this.ws = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleInput(data: string): void {
|
|
60
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
61
|
+
this.ws.send(JSON.stringify({ type: 'input', data }));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setDimensions(dims: vscode.TerminalDimensions): void {
|
|
66
|
+
this.cols = dims.columns;
|
|
67
|
+
this.rows = dims.rows;
|
|
68
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
this.ws.send(JSON.stringify({ type: 'resize', cols: this.cols, rows: this.rows }));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private connect(): void {
|
|
74
|
+
const ws = new WebSocket(this.opts.url);
|
|
75
|
+
this.ws = ws;
|
|
76
|
+
|
|
77
|
+
ws.on('open', () => {
|
|
78
|
+
const msg = this.opts.attach
|
|
79
|
+
? { type: 'attach', sessionName: this.opts.attach, cols: this.cols, rows: this.rows }
|
|
80
|
+
: { type: 'create', cols: this.cols, rows: this.rows, cwd: this.opts.cwd };
|
|
81
|
+
ws.send(JSON.stringify(msg));
|
|
82
|
+
this.writeEmitter.fire(this.opts.attach
|
|
83
|
+
? `\x1b[2m[forge] attaching to ${this.opts.attach}…\x1b[0m\r\n`
|
|
84
|
+
: `\x1b[2m[forge] creating new session…\x1b[0m\r\n`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
ws.on('message', (raw) => {
|
|
88
|
+
try {
|
|
89
|
+
const m = JSON.parse(raw.toString());
|
|
90
|
+
if (m.type === 'output' && typeof m.data === 'string') {
|
|
91
|
+
this.writeEmitter.fire(m.data);
|
|
92
|
+
} else if (m.type === 'error') {
|
|
93
|
+
this.writeEmitter.fire(`\r\n\x1b[31m[forge] ${m.message || 'error'}\x1b[0m\r\n`);
|
|
94
|
+
} else if (m.type === 'connected') {
|
|
95
|
+
// For freshly created sessions, auto-type the launch command (claude --resume ...).
|
|
96
|
+
if (this.opts.launchCommand && !this.opts.attach) {
|
|
97
|
+
// Small delay so the bash prompt is fully ready before we type.
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
100
|
+
this.ws.send(JSON.stringify({ type: 'input', data: this.opts.launchCommand + '\n' }));
|
|
101
|
+
}
|
|
102
|
+
}, 200);
|
|
103
|
+
}
|
|
104
|
+
} else if (m.type === 'sessions') {
|
|
105
|
+
// ignore — list response we didn't ask for
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore non-JSON
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
ws.on('close', () => {
|
|
113
|
+
if (this.opened) {
|
|
114
|
+
this.writeEmitter.fire('\r\n\x1b[2m[forge] terminal closed\x1b[0m\r\n');
|
|
115
|
+
this.closeEmitter.fire(0);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
ws.on('error', (err: Error) => {
|
|
120
|
+
this.writeEmitter.fire(`\r\n\x1b[31m[forge] WS error: ${err.message}\x1b[0m\r\n`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|