@aion0/forge 0.6.1 → 0.8.0

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.
Files changed (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -14,7 +14,8 @@ import { join, dirname } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import YAML from 'yaml';
17
- import type { PluginDefinition, InstalledPlugin, PluginSource } from './types';
17
+ import type { PluginDefinition, InstalledPlugin, PluginSource, Connector } from './types';
18
+ import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
18
19
 
19
20
  const _filename = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
20
21
  const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(_filename);
@@ -31,33 +32,153 @@ function loadPluginYaml(filePath: string): PluginDefinition | null {
31
32
  try {
32
33
  const raw = readFileSync(filePath, 'utf-8');
33
34
  const def = YAML.parse(raw) as PluginDefinition;
34
- if (!def.id || !def.name || !def.actions) return null;
35
+ if (!def.id || !def.name) return null;
36
+ // Connector plugins don't need `actions` (tools execute in the extension).
37
+ // Everything else must declare at least one action.
38
+ const isConnector = def.category === 'connector';
39
+ if (!isConnector && !def.actions) return null;
40
+ if (isConnector && !def.tools && !def.connectors?.length) return null;
35
41
  // Defaults
36
42
  if (!def.config) def.config = {};
37
43
  if (!def.params) def.params = {};
44
+ if (!def.actions) def.actions = {};
38
45
  if (!def.icon) def.icon = '🔌';
39
46
  if (!def.version) def.version = '0.0.1';
47
+ if (!def.category) def.category = isConnectorByShape(def) ? 'connector' : 'pipeline-node';
48
+ if (!def.mode) def.mode = def.category === 'connector' ? 'browser-side' : 'server-side';
40
49
  return def;
41
50
  } catch {
42
51
  return null;
43
52
  }
44
53
  }
45
54
 
55
+ /** Heuristic: shape-detect a connector even if `category` is omitted. */
56
+ function isConnectorByShape(def: PluginDefinition): boolean {
57
+ return Boolean(def.tools || def.connectors?.length);
58
+ }
59
+
60
+ /**
61
+ * Normalize a plugin to its connector list.
62
+ * - 1:1 shape (top-level `tools`) → synthesizes a single Connector with id === plugin id.
63
+ * - 1:N shape (`connectors[]`) → returned as-is.
64
+ * - Non-connector plugins → returns [].
65
+ */
66
+ export function getConnectorsForPlugin(def: PluginDefinition): Connector[] {
67
+ if (def.connectors?.length) return def.connectors;
68
+ if (def.tools) {
69
+ return [{
70
+ id: def.id,
71
+ host_permissions: def.host_permissions,
72
+ tools: def.tools,
73
+ settings: def.settings,
74
+ host_match: def.host_match,
75
+ login_redirect: def.login_redirect,
76
+ runner: def.runner,
77
+ }];
78
+ }
79
+ return [];
80
+ }
81
+
46
82
  // ─── Config Storage ──────────────────────────────────────
47
83
 
48
- function loadConfigs(): Record<string, { config: Record<string, any>; installedAt: string; enabled: boolean }> {
84
+ /**
85
+ * Return field names declared as `type: secret` (or legacy `password`)
86
+ * for the given plugin definition's top-level settings. Used to decide
87
+ * which config fields are encrypted on disk.
88
+ */
89
+ export function getSecretFieldNames(def: PluginDefinition | null): string[] {
90
+ if (!def?.settings) return [];
91
+ return Object.entries(def.settings)
92
+ .filter(([, schema]) => {
93
+ const t = String((schema as any)?.type || '');
94
+ return t === 'secret' || t === 'password';
95
+ })
96
+ .map(([name]) => name);
97
+ }
98
+
99
+ /** Resolve a config row's plugin def, accounting for instance `source`. */
100
+ function defForConfigRow(id: string, row: any): PluginDefinition | null {
101
+ const sourceId = (row?.source as string) || id;
102
+ // getPlugin scans YAML files directly — no loadConfigs call, so no
103
+ // cycle even though we're called from loadConfigs.
104
+ try { return getPlugin(sourceId); } catch { return null; }
105
+ }
106
+
107
+ function loadConfigs(): Record<string, { config: Record<string, any>; installedAt: string; enabled: boolean; source?: string; name?: string }> {
49
108
  try {
50
- if (existsSync(CONFIGS_FILE)) {
51
- return JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8'));
109
+ if (!existsSync(CONFIGS_FILE)) return {};
110
+ const raw = JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8')) as Record<string, any>;
111
+ // Decrypt any encrypted secret fields on read. Plaintext values pass
112
+ // through unchanged so a user-edited file still works (will be
113
+ // encrypted next save by migratePluginSecrets / installPlugin).
114
+ for (const [id, row] of Object.entries(raw)) {
115
+ const def = defForConfigRow(id, row);
116
+ const secrets = getSecretFieldNames(def);
117
+ const cfg = (row as any)?.config;
118
+ if (!cfg || !secrets.length) continue;
119
+ for (const k of secrets) {
120
+ if (typeof cfg[k] === 'string' && isEncrypted(cfg[k])) {
121
+ try { cfg[k] = decryptSecret(cfg[k]); }
122
+ catch (e) { console.warn(`[plugins] decrypt failed for ${id}.${k}:`, (e as Error).message); }
123
+ }
124
+ }
52
125
  }
53
- } catch {}
54
- return {};
126
+ return raw;
127
+ } catch (e) {
128
+ console.warn('[plugins] loadConfigs failed:', (e as Error).message);
129
+ return {};
130
+ }
55
131
  }
56
132
 
57
133
  function saveConfigs(configs: Record<string, any>): void {
58
134
  const dir = dirname(CONFIGS_FILE);
59
135
  mkdirSync(dir, { recursive: true });
60
- writeFileSync(CONFIGS_FILE, JSON.stringify(configs, null, 2));
136
+ // Encrypt any secret fields before persisting. Operate on a deep-ish
137
+ // copy so the in-memory object the caller still holds remains decrypted.
138
+ const out: Record<string, any> = {};
139
+ for (const [id, row] of Object.entries(configs)) {
140
+ const def = defForConfigRow(id, row);
141
+ const secrets = getSecretFieldNames(def);
142
+ const copy = { ...(row as any), config: { ...((row as any).config || {}) } };
143
+ for (const k of secrets) {
144
+ const v = copy.config[k];
145
+ if (typeof v === 'string' && v && !isEncrypted(v)) {
146
+ try { copy.config[k] = encryptSecret(v); }
147
+ catch (e) { console.warn(`[plugins] encrypt failed for ${id}.${k}:`, (e as Error).message); }
148
+ }
149
+ }
150
+ out[id] = copy;
151
+ }
152
+ writeFileSync(CONFIGS_FILE, JSON.stringify(out, null, 2), { mode: 0o600 });
153
+ }
154
+
155
+ /**
156
+ * One-shot migration: re-write plugin-configs.json so any plaintext
157
+ * secret fields get encrypted in place. Idempotent — calling it
158
+ * repeatedly is cheap once everything is already encrypted.
159
+ */
160
+ export function migratePluginSecrets(): void {
161
+ if (!existsSync(CONFIGS_FILE)) return;
162
+ let raw: Record<string, any>;
163
+ try { raw = JSON.parse(readFileSync(CONFIGS_FILE, 'utf-8')); }
164
+ catch { return; }
165
+ let dirty = 0;
166
+ for (const [id, row] of Object.entries(raw)) {
167
+ const def = defForConfigRow(id, row);
168
+ const secrets = getSecretFieldNames(def);
169
+ const cfg = row?.config;
170
+ if (!cfg || !secrets.length) continue;
171
+ for (const k of secrets) {
172
+ if (typeof cfg[k] === 'string' && cfg[k] && !isEncrypted(cfg[k])) {
173
+ cfg[k] = encryptSecret(cfg[k]);
174
+ dirty++;
175
+ }
176
+ }
177
+ }
178
+ if (dirty > 0) {
179
+ writeFileSync(CONFIGS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
180
+ console.log(`[plugins] Migrated ${dirty} plaintext secret(s) to encrypted storage`);
181
+ }
61
182
  }
62
183
 
63
184
  // ─── Public API ──────────────────────────────────────────
@@ -86,6 +207,8 @@ export function listPlugins(): PluginSource[] {
86
207
  source: 'builtin',
87
208
  installed: isInstalledOrHasInstance(def.id),
88
209
  configCount: Object.keys(def.config).length,
210
+ category: def.category,
211
+ mode: def.mode,
89
212
  });
90
213
  }
91
214
  }
@@ -113,6 +236,8 @@ export function listPlugins(): PluginSource[] {
113
236
  source: 'local',
114
237
  installed: isInstalledOrHasInstance(def.id),
115
238
  configCount: Object.keys(def.config).length,
239
+ category: def.category,
240
+ mode: def.mode,
116
241
  });
117
242
  }
118
243
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Template substitution for connector manifest fields.
3
+ *
4
+ * Expands tokens that depend on user settings ({base_url}, {settings.*}) when
5
+ * Forge serves the manifest to the extension. Tokens that depend on the LLM's
6
+ * tool arguments ({args.*}) are left literal — the extension's runner expands
7
+ * those at execution time.
8
+ *
9
+ * See docs/Connector-DeclarativeExtract-Spec.md §4.
10
+ */
11
+
12
+ type SettingsMap = Record<string, any>;
13
+
14
+ /**
15
+ * Expand {base_url} and {settings.<key>} tokens in a string. Other tokens
16
+ * (including {args.*}) are passed through unchanged so the extension can
17
+ * finish substitution at run time.
18
+ */
19
+ export function expandSettingsTokens(template: string, settings: SettingsMap | undefined): string {
20
+ if (!template || !settings) return template;
21
+ return template.replace(/\{([^{}]+)\}/g, (full, raw) => {
22
+ const key = String(raw).trim();
23
+ if (key === 'base_url') {
24
+ const v = settings.base_url;
25
+ return typeof v === 'string' ? stripTrailingSlashes(v) : full;
26
+ }
27
+ if (key.startsWith('settings.')) {
28
+ const path = key.slice('settings.'.length);
29
+ const v = settings[path];
30
+ return typeof v === 'string' ? stripTrailingSlashes(v) : full;
31
+ }
32
+ return full;
33
+ });
34
+ }
35
+
36
+ function stripTrailingSlashes(s: string): string {
37
+ return s.replace(/\/+$/, '');
38
+ }
39
+
40
+ /**
41
+ * Expand all tokens — `{base_url}`, `{settings.*}`, `{args.*}` — in one
42
+ * pass. Used by server-side protocols (http, shell) where Forge has both
43
+ * the settings and the LLM's parsed arguments in hand.
44
+ *
45
+ * Unknown tokens are left literal.
46
+ *
47
+ * `{args.*}` supports dotted paths (e.g. `{args.repo.full_name}`).
48
+ * Non-string values are JSON-encoded for object/array, coerced for
49
+ * number/boolean. base_url and settings get trailing slashes stripped;
50
+ * args do not (an args value is whatever the LLM passed).
51
+ */
52
+ export function expandAllTokens(
53
+ template: string,
54
+ settings: SettingsMap | undefined,
55
+ args: SettingsMap | undefined,
56
+ ): string {
57
+ if (!template) return template;
58
+ return template.replace(/\{([^{}]+)\}/g, (full, raw) => {
59
+ const key = String(raw).trim();
60
+ if (key === 'base_url') {
61
+ const v = settings?.base_url;
62
+ return typeof v === 'string' ? stripTrailingSlashes(v) : full;
63
+ }
64
+ if (key.startsWith('settings.')) {
65
+ const v = settings?.[key.slice('settings.'.length)];
66
+ if (v == null) return full;
67
+ return typeof v === 'string' ? v : String(v);
68
+ }
69
+ if (key.startsWith('args.')) {
70
+ const path = key.slice('args.'.length).split('.');
71
+ let v: any = args;
72
+ for (const p of path) {
73
+ if (v == null || typeof v !== 'object') { v = undefined; break; }
74
+ v = v[p];
75
+ }
76
+ if (v == null) return full;
77
+ if (typeof v === 'string') return v;
78
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
79
+ try { return JSON.stringify(v); } catch { return full; }
80
+ }
81
+ return full;
82
+ });
83
+ }
@@ -9,6 +9,18 @@
9
9
  /** Plugin action execution type */
10
10
  export type PluginActionType = 'http' | 'poll' | 'shell' | 'script';
11
11
 
12
+ /** What kind of plugin this is — drives marketplace filtering + extension surfacing */
13
+ export type PluginCategory = 'connector' | 'pipeline-node' | 'mcp-server' | 'tool' | 'other';
14
+
15
+ /** Where a plugin's tools execute. Default is server-side (Forge node process). */
16
+ export type PluginMode = 'server-side' | 'browser-side' | 'hybrid';
17
+
18
+ /**
19
+ * Which execution context the Forge browser extension uses to run a
20
+ * connector's scripts. See PluginDefinition.runner for the full rationale.
21
+ */
22
+ export type PluginRunner = 'main' | 'isolated';
23
+
12
24
  /** Schema field definition for config/params */
13
25
  export interface PluginFieldSchema {
14
26
  type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
@@ -47,6 +59,106 @@ export interface PluginAction {
47
59
  output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
48
60
  }
49
61
 
62
+ /**
63
+ * Where the extension's generic runner should land the active tab before
64
+ * executing a tool's script.
65
+ */
66
+ export interface ConnectorPage {
67
+ /** Template URL; supports {base_url}, {settings.*}, {args.*}. */
68
+ url: string;
69
+ /**
70
+ * Substring; if the current tab URL already contains it, skip navigation.
71
+ * Templated values are expanded the same way as `url` (settings server-side,
72
+ * args at runtime). Omit to always navigate.
73
+ */
74
+ on_target?: string;
75
+ }
76
+
77
+ /**
78
+ * A tool a browser-side connector exposes to the extension's LLM.
79
+ *
80
+ * The script body runs in the user's tab via chrome.scripting.executeScript
81
+ * (page context, has document/fetch/etc, no chrome.* APIs). The extension is
82
+ * a generic runner — it doesn't know about Mantis or GitLab specifically.
83
+ * Adding a new connector means dropping a manifest with `page` + `script`
84
+ * per tool; no extension rebuild required.
85
+ *
86
+ * See docs/Connector-DeclarativeExtract-Spec.md.
87
+ */
88
+ export interface ConnectorTool {
89
+ description: string;
90
+ parameters?: Record<string, PluginFieldSchema>;
91
+ /** Requires user confirmation before invoking (e.g. add_comment, close_issue). */
92
+ destructive?: boolean;
93
+ /** Free-form description of the return shape — surfaced to the LLM. */
94
+ returns?: string;
95
+ /** Where to navigate before running the script. */
96
+ page?: ConnectorPage;
97
+ /**
98
+ * JavaScript function body. Receives `args` (the LLM's parsed parameters).
99
+ * Returns a JSON-serializable value. Runs in the user's tab — must be
100
+ * self-contained (no closures over Forge/extension code).
101
+ */
102
+ script?: string;
103
+
104
+ // ─── Phase 3: multi-protocol tools (server-side execution) ──
105
+ /**
106
+ * Where the tool runs. Default 'browser' (extension runner via bridge).
107
+ * 'browser' — page DOM script via chrome.scripting (legacy default)
108
+ * 'http' — Forge issues an HTTP request server-side
109
+ * 'shell' — Forge spawns a process (arg array, no shell:true)
110
+ */
111
+ protocol?: 'browser' | 'http' | 'shell';
112
+ /** http protocol: request shape. Templates {base_url}, {settings.*}, {args.*}. */
113
+ request?: HttpRequestSpec;
114
+ /** shell protocol: command + args (each templated independently — no shell injection). */
115
+ command?: string[];
116
+ /** shell protocol: working directory (templated). */
117
+ cwd?: string;
118
+ /** shell protocol: extra env vars (values templated). */
119
+ env?: Record<string, string>;
120
+ /** shell/http: timeout in milliseconds. Default 30000, max 300000. */
121
+ timeout_ms?: number;
122
+ }
123
+
124
+ export interface HttpRequestSpec {
125
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
126
+ /** Full URL. Templated. */
127
+ url: string;
128
+ /** Header values are templated. */
129
+ headers?: Record<string, string>;
130
+ /** Query params. Values templated. Appended after any existing ? on url. */
131
+ query?: Record<string, string>;
132
+ /** Body. If string: sent as-is (after templating). If object: JSON.stringify'd; values templated where strings. */
133
+ body?: string | Record<string, unknown>;
134
+ }
135
+
136
+ /**
137
+ * One connector inside a plugin. Most plugins are 1:1 with a single connector
138
+ * declared via top-level `tools`/`host_permissions`/`settings` fields on
139
+ * PluginDefinition. Use `connectors[]` only when one auth covers multiple
140
+ * sibling adapters (Atlassian, Google Workspace, M365).
141
+ */
142
+ export interface Connector {
143
+ id: string;
144
+ host_permissions?: string[];
145
+ tools: Record<string, ConnectorTool>;
146
+ /** Per-user settings (host URL, default project, etc.) — rendered as a form by the extension. */
147
+ settings?: Record<string, PluginFieldSchema>;
148
+ /**
149
+ * Chrome match pattern. Runner uses this to find/open authenticated tabs
150
+ * for this connector. Supports {base_url}, {settings.*}.
151
+ */
152
+ host_match?: string;
153
+ /**
154
+ * Substring. If the tab URL contains this after a navigation, treat as
155
+ * "user not logged in" and abort with loginRequired: true.
156
+ */
157
+ login_redirect?: string;
158
+ /** See PluginDefinition.runner. */
159
+ runner?: PluginRunner;
160
+ }
161
+
50
162
  /** Plugin definition (loaded from plugin.yaml) */
51
163
  export interface PluginDefinition {
52
164
  id: string;
@@ -56,17 +168,42 @@ export interface PluginDefinition {
56
168
  author?: string;
57
169
  description?: string;
58
170
 
171
+ /** Classifies the plugin for marketplace + extension filtering. */
172
+ category?: PluginCategory;
173
+
174
+ /** Where this plugin's tools execute. Connector plugins are usually `browser-side`. */
175
+ mode?: PluginMode;
176
+
177
+ /**
178
+ * Which extension execution context runs this connector's scripts.
179
+ * Default `main` for back-compat (Mantis, GitLab — permissive-CSP sites).
180
+ * Use `isolated` for sites with strict CSP that blocks `unsafe-eval`
181
+ * (Teams, github.com, banks). Per-plugin, not per-tool — a host has
182
+ * one CSP profile.
183
+ */
184
+ runner?: PluginRunner;
185
+
59
186
  /** Global config — set once when installing the plugin */
60
187
  config: Record<string, PluginFieldSchema>;
61
188
 
62
189
  /** Per-use params — set each time the plugin is used in a pipeline node */
63
190
  params: Record<string, PluginFieldSchema>;
64
191
 
65
- /** Named actions this plugin can perform */
192
+ /** Named actions this plugin can perform (server-side execution path) */
66
193
  actions: Record<string, PluginAction>;
67
194
 
68
195
  /** Default action to run if none specified */
69
196
  defaultAction?: string;
197
+
198
+ // ─── Connector fields (category === 'connector') ─────────────────────────
199
+ /** 1:1 sugar — when present, treated as a single connector with id === plugin id. */
200
+ host_permissions?: string[];
201
+ tools?: Record<string, ConnectorTool>;
202
+ settings?: Record<string, PluginFieldSchema>;
203
+ host_match?: string;
204
+ login_redirect?: string;
205
+ /** 1:N escape hatch — only for same-vendor suites with shared auth. */
206
+ connectors?: Connector[];
70
207
  }
71
208
 
72
209
  /** Installed plugin instance (definition + user config values) */
@@ -100,4 +237,6 @@ export interface PluginSource {
100
237
  source: 'builtin' | 'local' | 'registry';
101
238
  installed: boolean;
102
239
  configCount: number; // number of config fields — 0 means no config needed
240
+ category?: PluginCategory;
241
+ mode?: PluginMode;
103
242
  }
@@ -40,7 +40,18 @@ export interface SessionWatcher {
40
40
 
41
41
  // ─── Session cache sync ──────────────────────────────────────
42
42
 
43
- export function syncSessionsToDb(projectName?: string) {
43
+ /**
44
+ * Sync session metadata to the cached_sessions table.
45
+ *
46
+ * @param projectName Optional — limit to one project.
47
+ * @param opts.fast Skip the expensive per-session entry_count update
48
+ * (reads the whole JSONL just to count lines). Used
49
+ * on initial startup sync where we have nothing to
50
+ * diff against yet; the next 30s watcher cycle fills
51
+ * entry_count in. Cuts startup by 3-5s on hosts with
52
+ * many sessions.
53
+ */
54
+ export function syncSessionsToDb(projectName?: string, opts: { fast?: boolean } = {}) {
44
55
  const db = getDb(DB_PATH);
45
56
  const projects = projectName
46
57
  ? [{ name: projectName }]
@@ -75,13 +86,17 @@ export function syncSessionsToDb(projectName?: string) {
75
86
  s.gitBranch || null, s.fileSize,
76
87
  );
77
88
 
78
- // Count entries for watcher comparison
79
- const fp = getSessionFilePath(proj.name, s.sessionId);
80
- if (fp) {
81
- try {
82
- const entries = readSessionEntries(fp);
83
- updateEntryCount.run(entries.length, proj.name, s.sessionId);
84
- } catch {}
89
+ // Skip entry_count on fast path — it parses the whole JSONL file
90
+ // and is the dominant cost when initial-syncing hundreds of sessions.
91
+ // The 30s watcher cycle will catch up.
92
+ if (!opts.fast) {
93
+ const fp = getSessionFilePath(proj.name, s.sessionId);
94
+ if (fp) {
95
+ try {
96
+ const entries = readSessionEntries(fp);
97
+ updateEntryCount.run(entries.length, proj.name, s.sessionId);
98
+ } catch {}
99
+ }
85
100
  }
86
101
 
87
102
  totalSynced++;
@@ -201,8 +216,19 @@ let watcherInterval: ReturnType<typeof setInterval> | null = null;
201
216
  export function startWatcherLoop() {
202
217
  if (watcherInterval) return;
203
218
 
204
- // Initial sync
205
- try { syncSessionsToDb(); } catch (e) { console.error('[watcher] Initial sync error:', e); }
219
+ // Defer the initial sync — it was blocking ensureInitialized() by 3-5s
220
+ // on hosts with many sessions (~/.claude/projects/<proj>/*.jsonl per-file
221
+ // parse for entry_count). setImmediate yields the event loop first so
222
+ // the API server starts answering requests; the sync runs in the
223
+ // background. Also pass fast=true to skip the heavy entry_count fill —
224
+ // the 30s watcher cycle picks that up later.
225
+ setImmediate(() => {
226
+ const t0 = Date.now();
227
+ try { syncSessionsToDb(undefined, { fast: true }); }
228
+ catch (e) { console.error('[watcher] Initial sync error:', e); }
229
+ const ms = Date.now() - t0;
230
+ if (ms >= 500) console.log(`[watcher] Initial sync (fast) took ${ms}ms`);
231
+ });
206
232
 
207
233
  // Check every 30 seconds
208
234
  watcherInterval = setInterval(runWatcherCheck, 30_000);
package/lib/settings.ts CHANGED
@@ -18,18 +18,33 @@ export interface AgentEntry {
18
18
  base?: string; // base agent ID (e.g., 'claude') — makes this a profile
19
19
  // API profile fields
20
20
  type?: 'cli' | 'api'; // 'api' = API mode, default = 'cli'
21
- provider?: string; // API provider (e.g., 'anthropic', 'google')
21
+ provider?: string; // API provider label (e.g., 'anthropic', 'openai', 'litellm', 'grok', 'google')
22
22
  model?: string; // model override (for both CLI and API profiles)
23
23
  apiKey?: string; // per-profile API key (encrypted)
24
+ /**
25
+ * Base URL override for API profiles. Use this for LiteLLM / Azure /
26
+ * self-hosted proxies. Empty = default for the provider's protocol.
27
+ * For CLI profiles, set base URL via `env` (ANTHROPIC_BASE_URL etc.).
28
+ */
29
+ baseUrl?: string;
24
30
  env?: Record<string, string>; // environment variables injected when spawning CLI
25
31
  cliType?: 'claude-code' | 'codex' | 'aider' | 'generic'; // CLI tool type — determines session support, resume flags, etc.
26
32
  profile?: string; // linked profile ID — overrides model, env, etc. when launching
27
33
  }
28
34
 
29
- export interface ProviderEntry {
30
- apiKey?: string; // encrypted, fallback to env var
31
- defaultModel?: string;
32
- enabled?: boolean;
35
+ /**
36
+ * External MCP server configuration — merged into per-project .mcp.json
37
+ * alongside the built-in `forge` entry. Format mirrors the MCP standard.
38
+ */
39
+ export interface McpServerConfig {
40
+ type: 'stdio' | 'sse' | 'streamable-http';
41
+ // stdio
42
+ command?: string;
43
+ args?: string[];
44
+ env?: Record<string, string>;
45
+ // sse / streamable-http
46
+ url?: string;
47
+ headers?: Record<string, string>;
33
48
  }
34
49
 
35
50
  export interface Settings {
@@ -56,8 +71,34 @@ export interface Settings {
56
71
  defaultAgent: string;
57
72
  telegramAgent: string;
58
73
  docsAgent: string;
74
+ /** Default API agent profile used by Forge's chat backend (extension / web / CLI / Telegram /chat). */
75
+ chatAgent: string;
76
+ /**
77
+ * Temper memory service — long-term memory backend for the chat agent.
78
+ * When url + key are both set, chat fetches pinned blocks / search hits
79
+ * on every turn and exposes memory_* tools to the LLM.
80
+ */
81
+ temperUrl: string;
82
+ temperKey: string;
83
+ /** Optional namespace override (empty = use the key's default namespace). */
84
+ temperNamespace: string;
85
+ /**
86
+ * Which memory backend the chat agent uses.
87
+ * 'auto' — Temper if URL+key are set, otherwise local SQLite (default).
88
+ * 'local' — always local SQLite, even if Temper credentials are filled.
89
+ * 'temper' — always Temper; if credentials are missing, memory is disabled.
90
+ */
91
+ memoryBackend: 'auto' | 'local' | 'temper';
59
92
  agents: Record<string, AgentEntry>;
60
- providers: Record<string, ProviderEntry>; // API provider configs
93
+ /** Extra MCP servers merged into each project's .mcp.json (chrome-devtools-mcp etc.) */
94
+ mcpServers: Record<string, McpServerConfig>;
95
+ /**
96
+ * Preferred timezone for rendering timestamps in the UI. Format: IANA
97
+ * (e.g. "America/Los_Angeles", "Asia/Shanghai", "Europe/Berlin").
98
+ * Empty = use the OS / browser default (Intl.DateTimeFormat().resolvedOptions().timeZone).
99
+ * Used by the extension's date formatter for chat / jobs / pipeline run timestamps.
100
+ */
101
+ timezone: string;
61
102
  }
62
103
 
63
104
  const defaults: Settings = {
@@ -84,39 +125,36 @@ const defaults: Settings = {
84
125
  defaultAgent: 'claude',
85
126
  telegramAgent: '',
86
127
  docsAgent: '',
128
+ chatAgent: '',
129
+ temperUrl: '',
130
+ temperKey: '',
131
+ temperNamespace: '',
132
+ memoryBackend: 'auto',
87
133
  agents: {},
88
- providers: {},
134
+ mcpServers: {},
135
+ timezone: '',
89
136
  };
90
137
 
91
- /** Decrypt nested apiKey fields in agents and providers */
138
+ /** Decrypt nested apiKey fields in agent profiles */
92
139
  function decryptNestedSecrets(settings: Settings): void {
93
- // Decrypt provider apiKeys
94
- if (settings.providers) {
95
- for (const p of Object.values(settings.providers)) {
96
- if (p.apiKey && isEncrypted(p.apiKey)) {
97
- p.apiKey = decryptSecret(p.apiKey);
98
- }
99
- }
100
- }
101
- // Decrypt agent profile apiKeys
102
140
  if (settings.agents) {
103
- for (const a of Object.values(settings.agents)) {
141
+ for (const [id, a] of Object.entries(settings.agents)) {
104
142
  if (a.apiKey && isEncrypted(a.apiKey)) {
105
143
  a.apiKey = decryptSecret(a.apiKey);
106
144
  }
145
+ // Defensive: a past bug let the masked placeholder get encrypted
146
+ // and written back. Clear it so callers see "no key" instead of
147
+ // trying to send '••••••••' as a Bearer header.
148
+ if (a.apiKey && /^•+$/.test(a.apiKey)) {
149
+ console.warn(`[settings] agent '${id}' has a placeholder apiKey — clearing. Re-enter it via Settings.`);
150
+ a.apiKey = '';
151
+ }
107
152
  }
108
153
  }
109
154
  }
110
155
 
111
- /** Encrypt nested apiKey fields in agents and providers */
156
+ /** Encrypt nested apiKey fields in agent profiles */
112
157
  function encryptNestedSecrets(settings: Settings): void {
113
- if (settings.providers) {
114
- for (const p of Object.values(settings.providers)) {
115
- if (p.apiKey && !isEncrypted(p.apiKey)) {
116
- p.apiKey = encryptSecret(p.apiKey);
117
- }
118
- }
119
- }
120
158
  if (settings.agents) {
121
159
  for (const a of Object.values(settings.agents)) {
122
160
  if (a.apiKey && !isEncrypted(a.apiKey)) {
@@ -154,13 +192,7 @@ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string,
154
192
  status[field] = !!settings[field];
155
193
  settings[field] = settings[field] ? '••••••••' : '';
156
194
  }
157
- // Mask nested apiKeys
158
- if (settings.providers) {
159
- for (const [name, p] of Object.entries(settings.providers)) {
160
- status[`providers.${name}.apiKey`] = !!p.apiKey;
161
- p.apiKey = p.apiKey ? '••••••••' : '';
162
- }
163
- }
195
+ // Mask nested apiKeys (agent profiles only)
164
196
  if (settings.agents) {
165
197
  for (const [name, a] of Object.entries(settings.agents)) {
166
198
  if (a.apiKey) {
package/lib/skills.ts CHANGED
@@ -187,7 +187,9 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
187
187
  return { synced: rawItems.length, enriched, total: totalCount, remaining: Math.max(0, remaining) };
188
188
  } catch (e) {
189
189
  const msg = e instanceof Error ? e.message : String(e);
190
- console.error(`[skills] Sync failed:`, msg);
190
+ // Corp networks routinely can't reach GitHub raw — this is expected
191
+ // and shouldn't look scary in the startup log. Demote to warn.
192
+ console.warn(`[skills] Sync failed (continuing offline):`, msg);
191
193
  return { synced: 0, enriched: 0, error: msg };
192
194
  }
193
195
  }