@aion0/forge 0.10.78 → 0.10.79
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 +6 -8
- package/app/api/code/route.ts +171 -54
- package/app/api/onboarding/route.ts +32 -0
- package/app/api/skills/local/route.ts +5 -4
- package/components/CodeViewer.tsx +127 -41
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/WebTerminal.tsx +19 -8
- package/components/WorkspaceView.tsx +68 -47
- package/lib/agents/index.ts +9 -0
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/session-utils.ts +19 -0
- package/lib/terminal-standalone.ts +17 -0
- package/package.json +1 -1
|
@@ -209,6 +209,9 @@ function MouseToggle() {
|
|
|
209
209
|
// ─── Pending commands for new terminal panes ────────────────
|
|
210
210
|
|
|
211
211
|
const pendingCommands = new Map<number, string>();
|
|
212
|
+
// Secret env (API keys) to inject via `tmux set-environment` on connect, so the
|
|
213
|
+
// launch command never types `export KEY=value` into the pane (no echo/history).
|
|
214
|
+
const pendingEnv = new Map<number, Record<string, string>>();
|
|
212
215
|
|
|
213
216
|
// ─── Bell notification tracking ─────────────────────────────
|
|
214
217
|
|
|
@@ -439,14 +442,14 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
439
442
|
// Model flag from profile
|
|
440
443
|
const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
441
444
|
|
|
442
|
-
// Build env
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const envPrefix =
|
|
445
|
+
// Build env injection from profile (exclude CLAUDE_MODEL — passed via
|
|
446
|
+
// --model). Secret values go via `tmux set-environment` (sent on connect);
|
|
447
|
+
// the prefix only references var NAMES so keys never echo into the pane.
|
|
448
|
+
const envEntries = profileEnv
|
|
449
|
+
? Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
450
|
+
: [];
|
|
451
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
452
|
+
const envPrefix = tmuxEnvPrefix(envEntries.map(([k]) => k));
|
|
450
453
|
|
|
451
454
|
// Skip-permissions flag. agent === 'claude' means the agent ID is
|
|
452
455
|
// the base claude — not the resolved path. The check must compare
|
|
@@ -479,6 +482,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
479
482
|
const tree = makeTerminal(undefined, projectPath);
|
|
480
483
|
const paneId = firstTerminalId(tree);
|
|
481
484
|
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
|
|
485
|
+
if (envEntries.length) pendingEnv.set(paneId, Object.fromEntries(envEntries));
|
|
482
486
|
const newTab: TabState = {
|
|
483
487
|
id: nextId++,
|
|
484
488
|
label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
|
|
@@ -1613,6 +1617,13 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1613
1617
|
const cmd = pendingCommands.get(id);
|
|
1614
1618
|
if (cmd) {
|
|
1615
1619
|
pendingCommands.delete(id);
|
|
1620
|
+
// Inject secret env into the tmux session first (no echo), so the
|
|
1621
|
+
// launch command can pull it via `tmux show-environment`.
|
|
1622
|
+
const penv = pendingEnv.get(id);
|
|
1623
|
+
if (penv && connectedSession && ws?.readyState === WebSocket.OPEN) {
|
|
1624
|
+
pendingEnv.delete(id);
|
|
1625
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: connectedSession, env: penv }));
|
|
1626
|
+
}
|
|
1616
1627
|
setTimeout(() => {
|
|
1617
1628
|
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1618
1629
|
ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
4
|
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
5
|
import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
|
|
6
|
+
import { updateTreeChildren } from '@/lib/fileTree';
|
|
6
7
|
import {
|
|
7
8
|
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
8
9
|
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
@@ -559,25 +560,47 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
559
560
|
const [search, setSearch] = useState('');
|
|
560
561
|
const [flatFiles, setFlatFiles] = useState<string[]>([]);
|
|
561
562
|
|
|
563
|
+
// Full searchable path list comes from the server's bounded index (files +
|
|
564
|
+
// dirs), computed once. Falls back to walking the loaded tree only if an
|
|
565
|
+
// older server omits the index.
|
|
566
|
+
const buildSearchList = useCallback((data: any) => {
|
|
567
|
+
const fi = data?.fileIndex, di = data?.dirIndex;
|
|
568
|
+
if (Array.isArray(fi) || Array.isArray(di)) {
|
|
569
|
+
return [
|
|
570
|
+
...(di || []).map((d: any) => `${d.path}/`),
|
|
571
|
+
...(fi || []).map((f: any) => f.path),
|
|
572
|
+
];
|
|
573
|
+
}
|
|
574
|
+
const files: string[] = [];
|
|
575
|
+
const walk = (items: any[]) => {
|
|
576
|
+
for (const n of items || []) {
|
|
577
|
+
files.push(n.type === 'dir' ? `${n.path}/` : n.path);
|
|
578
|
+
if (n.children) walk(n.children);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
walk(data?.tree || []);
|
|
582
|
+
return files;
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
562
585
|
const loadTree = useCallback(() => {
|
|
563
586
|
if (!projectPath) return;
|
|
564
587
|
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
565
588
|
.then(r => r.json())
|
|
566
589
|
.then(data => {
|
|
567
590
|
setTree(data.tree || []);
|
|
568
|
-
|
|
569
|
-
const files: string[] = [];
|
|
570
|
-
const walk = (nodes: any[], prefix = '') => {
|
|
571
|
-
for (const n of nodes || []) {
|
|
572
|
-
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
573
|
-
files.push(n.type === 'dir' ? path + '/' : path);
|
|
574
|
-
if (n.children) walk(n.children, path);
|
|
575
|
-
}
|
|
576
|
-
};
|
|
577
|
-
walk(data.tree || []);
|
|
578
|
-
setFlatFiles(files);
|
|
591
|
+
setFlatFiles(buildSearchList(data));
|
|
579
592
|
})
|
|
580
593
|
.catch(() => {});
|
|
594
|
+
}, [buildSearchList, projectPath]);
|
|
595
|
+
|
|
596
|
+
const loadChildren = useCallback(async (path: string) => {
|
|
597
|
+
if (!projectPath) return;
|
|
598
|
+
try {
|
|
599
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
// Tree display only — search already covers the whole repo via the index.
|
|
602
|
+
setTree(prev => updateTreeChildren(prev, path, data.tree || []));
|
|
603
|
+
} catch {}
|
|
581
604
|
}, [projectPath]);
|
|
582
605
|
|
|
583
606
|
const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
|
|
@@ -613,7 +636,7 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
613
636
|
)) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
|
|
614
637
|
) : (
|
|
615
638
|
// Tree view (first 2 levels)
|
|
616
|
-
tree.map(n => <PathTreeNode key={n.
|
|
639
|
+
tree.map(n => <PathTreeNode key={n.path} node={n} onSelect={p => { onChange(p); setShowBrowser(false); }} onLoadChildren={loadChildren} />)
|
|
617
640
|
)}
|
|
618
641
|
</div>
|
|
619
642
|
<div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
|
|
@@ -626,21 +649,34 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
626
649
|
);
|
|
627
650
|
}
|
|
628
651
|
|
|
629
|
-
function PathTreeNode({ node,
|
|
630
|
-
const [expanded, setExpanded] = useState(
|
|
631
|
-
const path =
|
|
652
|
+
function PathTreeNode({ node, onSelect, onLoadChildren, depth = 0 }: { node: any; onSelect: (path: string) => void; onLoadChildren: (path: string) => Promise<void>; depth?: number }) {
|
|
653
|
+
const [expanded, setExpanded] = useState(false);
|
|
654
|
+
const path = node.path;
|
|
632
655
|
const isDir = node.type === 'dir';
|
|
656
|
+
const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
|
|
633
657
|
|
|
634
658
|
if (!isDir && depth > 1) return null; // only show files at top 2 levels
|
|
635
659
|
|
|
660
|
+
const toggleExpanded = async () => {
|
|
661
|
+
if (!isDir) {
|
|
662
|
+
onSelect(path);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const nextExpanded = !expanded;
|
|
666
|
+
setExpanded(nextExpanded);
|
|
667
|
+
if (nextExpanded && hasChildren && !node.children) {
|
|
668
|
+
await onLoadChildren(path);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
636
672
|
return (
|
|
637
673
|
<div>
|
|
638
674
|
<div
|
|
639
|
-
onClick={
|
|
675
|
+
onClick={toggleExpanded}
|
|
640
676
|
className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
|
|
641
677
|
style={{ paddingLeft: 8 + depth * 12 }}
|
|
642
678
|
>
|
|
643
|
-
<span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? '▼' : '▶') : ''}</span>
|
|
679
|
+
<span className="text-gray-500 mr-1 w-3">{isDir && hasChildren ? (expanded ? '▼' : '▶') : ''}</span>
|
|
644
680
|
<span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '📁' : '📄'} {node.name}</span>
|
|
645
681
|
{isDir && (
|
|
646
682
|
<button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
|
|
@@ -648,7 +684,7 @@ function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix
|
|
|
648
684
|
)}
|
|
649
685
|
</div>
|
|
650
686
|
{isDir && expanded && node.children && depth < 2 && (
|
|
651
|
-
node.children.map((c: any) => <PathTreeNode key={c.
|
|
687
|
+
node.children.map((c: any) => <PathTreeNode key={c.path} node={c} onSelect={onSelect} onLoadChildren={onLoadChildren} depth={depth + 1} />)
|
|
652
688
|
)}
|
|
653
689
|
</div>
|
|
654
690
|
);
|
|
@@ -782,29 +818,6 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
782
818
|
const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
|
|
783
819
|
initial.watch?.targets || []
|
|
784
820
|
);
|
|
785
|
-
const [projectDirs, setProjectDirs] = useState<string[]>([]);
|
|
786
|
-
|
|
787
|
-
useEffect(() => {
|
|
788
|
-
if (!watchEnabled || !projectPath) return;
|
|
789
|
-
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
790
|
-
.then(r => r.json())
|
|
791
|
-
.then(data => {
|
|
792
|
-
// Collect directories with depth limit (max 2 levels for readability)
|
|
793
|
-
const dirs: string[] = [];
|
|
794
|
-
const walk = (nodes: any[], prefix = '', depth = 0) => {
|
|
795
|
-
for (const n of nodes || []) {
|
|
796
|
-
if (n.type === 'dir') {
|
|
797
|
-
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
798
|
-
dirs.push(path);
|
|
799
|
-
if (n.children && depth < 2) walk(n.children, path, depth + 1);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
};
|
|
803
|
-
walk(data.tree || []);
|
|
804
|
-
setProjectDirs(dirs);
|
|
805
|
-
})
|
|
806
|
-
.catch(() => {});
|
|
807
|
-
}, [watchEnabled, projectPath]);
|
|
808
821
|
|
|
809
822
|
const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
|
|
810
823
|
setLabel(p.label); setIcon(p.icon); setRole(p.role);
|
|
@@ -2296,8 +2309,11 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
|
|
|
2296
2309
|
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2297
2310
|
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2298
2311
|
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2299
|
-
if (envWithoutForge.length > 0) {
|
|
2300
|
-
|
|
2312
|
+
if (envWithoutForge.length > 0 && msg.sessionName) {
|
|
2313
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: msg.sessionName, env: Object.fromEntries(envWithoutForge) }));
|
|
2314
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
2315
|
+
const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
|
|
2316
|
+
if (wrap) commands.push(wrap.replace(/ && $/, ''));
|
|
2301
2317
|
}
|
|
2302
2318
|
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2303
2319
|
if (forgeVars.length > 0) {
|
|
@@ -2541,13 +2557,18 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2541
2557
|
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2542
2558
|
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2543
2559
|
|
|
2544
|
-
// 2.
|
|
2560
|
+
// 2. Profile vars (secrets) — inject via `tmux set-environment` (no
|
|
2561
|
+
// echo), then reference by NAME in the launch command so API keys
|
|
2562
|
+
// never appear in the pane or shell history.
|
|
2545
2563
|
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2546
|
-
if (envWithoutForge.length > 0) {
|
|
2547
|
-
|
|
2564
|
+
if (envWithoutForge.length > 0 && sessionNameRef.current) {
|
|
2565
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: sessionNameRef.current, env: Object.fromEntries(envWithoutForge) }));
|
|
2566
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
2567
|
+
const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
|
|
2568
|
+
if (wrap) commands.push(wrap.replace(/ && $/, ''));
|
|
2548
2569
|
}
|
|
2549
2570
|
|
|
2550
|
-
// 3. Export FORGE vars
|
|
2571
|
+
// 3. Export FORGE vars (not secret — plain export is fine)
|
|
2551
2572
|
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2552
2573
|
if (forgeVars.length > 0) {
|
|
2553
2574
|
commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
package/lib/agents/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync } from 'node:child_process';
|
|
7
7
|
import { loadSettings } from '../settings';
|
|
8
|
+
import { decryptSecret } from '../crypto';
|
|
8
9
|
|
|
9
10
|
// Cache absolute-path resolution per command — `which <cmd>` is cheap
|
|
10
11
|
// but resolveTerminalLaunch is on the hot path for every chat tool call.
|
|
@@ -311,6 +312,14 @@ export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'tas
|
|
|
311
312
|
if (model === 'default') model = undefined;
|
|
312
313
|
}
|
|
313
314
|
}
|
|
315
|
+
// Decrypt any enc:… env values (e.g. CLAUDE_CODE_OAUTH_TOKEN written by the
|
|
316
|
+
// onboarding wizard). decryptSecret is a no-op on plaintext, so template-
|
|
317
|
+
// derived plaintext env passes through untouched.
|
|
318
|
+
if (env) {
|
|
319
|
+
for (const k of Object.keys(env)) {
|
|
320
|
+
if (typeof env[k] === 'string' && env[k].startsWith('enc:')) env[k] = decryptSecret(env[k]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
314
323
|
|
|
315
324
|
return {
|
|
316
325
|
// Prefer the user-configured absolute binary path over the bare command
|
package/lib/fileTree.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared helpers for the lazily-loaded file trees served by /api/code.
|
|
2
|
+
// The tree is fetched one directory level at a time; children for a directory
|
|
3
|
+
// are merged in on demand. Kept generic so each consumer can use its own node
|
|
4
|
+
// shape (CodeViewer, ProjectDetail, WorkspaceView) as long as it carries a
|
|
5
|
+
// `path` and an optional `children` array.
|
|
6
|
+
|
|
7
|
+
export interface FileTreeNode {
|
|
8
|
+
path: string;
|
|
9
|
+
children?: FileTreeNode[];
|
|
10
|
+
hasChildren?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return a new tree with `children` spliced in under the node whose `path`
|
|
15
|
+
* matches. Pure — does not mutate the input. `hasChildren` is recomputed so a
|
|
16
|
+
* directory that turns out to be empty loses its expand affordance.
|
|
17
|
+
*/
|
|
18
|
+
export function updateTreeChildren<T extends { path: string; children?: T[]; hasChildren?: boolean }>(
|
|
19
|
+
nodes: T[],
|
|
20
|
+
path: string,
|
|
21
|
+
children: T[],
|
|
22
|
+
): T[] {
|
|
23
|
+
return nodes.map(node => {
|
|
24
|
+
if (node.path === path) return { ...node, children, hasChildren: children.length > 0 };
|
|
25
|
+
if (node.children) return { ...node, children: updateTreeChildren(node.children, path, children) };
|
|
26
|
+
return node;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -53,6 +53,17 @@ Each agent entry in `settings.agents` supports:
|
|
|
53
53
|
| `models` | object | Model overrides per context: `terminal`, `task`, `telegram`, `help`, `mobile` |
|
|
54
54
|
| `profile` | string | Linked profile ID — applies that profile's env/model when launching |
|
|
55
55
|
|
|
56
|
+
### Claude authentication (host vs container)
|
|
57
|
+
|
|
58
|
+
The onboarding wizard's **CLI Agent** step detects your deployment shape and adapts how Claude Code authenticates:
|
|
59
|
+
|
|
60
|
+
- **Host** (desktop/laptop): run `claude login` once in a terminal after onboarding — it stores credentials in the OS keychain.
|
|
61
|
+
- **Container** (Docker / k8s): `claude login` can't reach a keychain. Instead, on a machine where you *can* log in, run `claude setup-token`, then paste the resulting token into the wizard. It's saved encrypted as `CLAUDE_CODE_OAUTH_TOKEN` in the agent's `env` and injected at launch.
|
|
62
|
+
|
|
63
|
+
Detection signals (any → container): `FORGE_CONTAINER=1`, `/.dockerenv`, or a containerized cgroup. The wizard pre-selects the right mode but you can override it.
|
|
64
|
+
|
|
65
|
+
Agent `env` secrets (including the OAuth token) are encrypted at rest (`enc:…`) and injected into terminals via `tmux set-environment`, so they never appear in the pane echo or shell history.
|
|
66
|
+
|
|
56
67
|
### CLI Type
|
|
57
68
|
|
|
58
69
|
The `cliType` field determines how Forge interacts with the agent:
|
|
@@ -38,7 +38,9 @@ Notes:
|
|
|
38
38
|
## Features
|
|
39
39
|
|
|
40
40
|
### Code Tab
|
|
41
|
-
- File tree browser
|
|
41
|
+
- File tree browser — loads lazily (folders expand on demand), so very large repos open instantly
|
|
42
|
+
- File search uses a fast server-built index; in very large repos the index is sampled per directory, and a note appears when results may be partial
|
|
43
|
+
- "Locate in file tree" (on a changed file) reveals it in the tree
|
|
42
44
|
- Syntax-highlighted code viewer
|
|
43
45
|
- Git diff view (click changed files)
|
|
44
46
|
- Git operations: commit, push, pull
|
package/lib/session-utils.ts
CHANGED
|
@@ -51,3 +51,22 @@ export async function getMcpFlag(projectPath: string): Promise<string> {
|
|
|
51
51
|
return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* tmux env injection — keep secret env (API keys) out of pane echo + shell
|
|
57
|
+
* history. The server stores values via `tmux set-environment` (the `setenv` WS
|
|
58
|
+
* message); this prefix pulls them back at launch time so only var NAMES appear
|
|
59
|
+
* in the typed command, never the values.
|
|
60
|
+
*
|
|
61
|
+
* Uses a single `eval "$(tmux show-environment -s | grep …)"` instead of one
|
|
62
|
+
* `export K="$(tmux show-environment K | sed …)"` per var — the latter form
|
|
63
|
+
* grows O(N) in length and hits tmux send-keys buffer limits with many vars.
|
|
64
|
+
* `tmux show-environment -s` outputs `KEY="val"; export KEY;` per line, so
|
|
65
|
+
* grepping `^(K1|K2)=` and eval-ing the matches is both short and safe.
|
|
66
|
+
*/
|
|
67
|
+
export function tmuxEnvPrefix(keys: string[]): string {
|
|
68
|
+
const valid = keys.filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k));
|
|
69
|
+
if (!valid.length) return '';
|
|
70
|
+
const pattern = `^(${valid.join('|')})=`;
|
|
71
|
+
return `eval "$(tmux show-environment -s 2>/dev/null | grep -E '${pattern}')" && `;
|
|
72
|
+
}
|
|
@@ -424,6 +424,23 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
424
424
|
break;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
case 'setenv': {
|
|
428
|
+
// Store secret env (API keys) into the tmux SESSION environment so the
|
|
429
|
+
// launch command can pull them via `tmux show-environment` instead of
|
|
430
|
+
// typing `export KEY=value` into the pane — keeps secrets out of the
|
|
431
|
+
// terminal echo and shell history. Values never touch the typed line.
|
|
432
|
+
const name = parsed.sessionName;
|
|
433
|
+
const env = parsed.env;
|
|
434
|
+
if (name && tmuxSessionExists(name) && env && typeof env === 'object') {
|
|
435
|
+
for (const [k, v] of Object.entries(env)) {
|
|
436
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
437
|
+
const val = `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
438
|
+
try { execSync(`${TMUX} set-environment -t "${name}" ${k} ${val}`, { timeout: 3000 }); } catch {}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
|
|
427
444
|
case 'load-state': {
|
|
428
445
|
const state = loadTerminalState();
|
|
429
446
|
ws.send(JSON.stringify({ type: 'terminal-state', data: state }));
|
package/package.json
CHANGED