ompchamber 3.1.0 → 3.2.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.
- package/README.md +6 -3
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/output.js +15 -8
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- package/dist/client/static/js/index-n4wkqj7t.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hoist every nested at-rule out of the style rule that contains it.
|
|
8
|
+
*
|
|
9
|
+
* Bun's CSS minifier keeps the nesting it is handed, and for a target that is
|
|
10
|
+
* not the browser (`target: 'bun'`, which this build needs because its
|
|
11
|
+
* entrypoint is the SERVER) it re-nests a rule's nested at-rule into
|
|
12
|
+
* `@supports (…) { & { … } }`. Chrome does not apply a nested rule whose parent
|
|
13
|
+
* selector names a pseudo-element, so `&` under `::placeholder` or
|
|
14
|
+
* `::-webkit-scrollbar-thumb` is dropped and the rule's earlier fallback
|
|
15
|
+
* declaration wins instead — measured on the built bundle: the composer
|
|
16
|
+
* placeholder painted at full ink rather than 40%, every scrollbar thumb opaque
|
|
17
|
+
* rather than 20%.
|
|
18
|
+
*
|
|
19
|
+
* Tailwind v4 emits its colour fallbacks in exactly that shape (`color:
|
|
20
|
+
* var(--x); @supports (color: color-mix(…)) { color: color-mix(…) }`), so one
|
|
21
|
+
* minifier decision reached 16 rules: every placeholder utility in the app and
|
|
22
|
+
* every scrollbar rule, base and xterm included. The rsbuild pipeline this
|
|
23
|
+
* replaced compiled for browsers and flattened the nesting itself.
|
|
24
|
+
*
|
|
25
|
+
* Flattening here is what makes the bundle immune: the emitted CSS is
|
|
26
|
+
* `@supports (…) { .sel::placeholder { … } }` at the top level, which no
|
|
27
|
+
* minifier has anything left to re-nest and every browser applies. Declaration
|
|
28
|
+
* order is preserved exactly — a rule's declarations stay in the segments they
|
|
29
|
+
* were written in, and each at-rule is hoisted to the position it occupied — so
|
|
30
|
+
* the cascade is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import postcss from 'postcss';
|
|
34
|
+
import type { AtRule, ChildNode, Container, Plugin, Rule } from 'postcss';
|
|
35
|
+
|
|
36
|
+
/** Resolve a nested selector against the rule it was written inside. */
|
|
37
|
+
function resolveSelector(parentSelector: string, nestedSelector: string): string {
|
|
38
|
+
if (nestedSelector.includes('&')) return nestedSelector.replaceAll('&', parentSelector);
|
|
39
|
+
return `${parentSelector} ${nestedSelector}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve `&` in the rules directly inside `container`, descending through
|
|
44
|
+
* at-rules only: `&` still refers to the nearest enclosing STYLE rule, and an
|
|
45
|
+
* intermediate one is hoisted on a later pass.
|
|
46
|
+
*
|
|
47
|
+
* Runs BEFORE `wrapBareDeclarations` — the rule that wraps bare declarations
|
|
48
|
+
* already carries the parent's resolved selector, and resolving it again would
|
|
49
|
+
* turn `::placeholder` into `::placeholder ::placeholder`.
|
|
50
|
+
*/
|
|
51
|
+
function resolveAmpersands(container: Container, parentSelector: string): void {
|
|
52
|
+
for (const node of container.nodes ?? []) {
|
|
53
|
+
if (node.type === 'rule') node.selector = resolveSelector(parentSelector, node.selector);
|
|
54
|
+
else if (node.type === 'atrule') resolveAmpersands(node, parentSelector);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap an at-rule's bare declarations in `parentSelector`, keeping them in place. */
|
|
59
|
+
function wrapBareDeclarations(atRule: AtRule, parentSelector: string): void {
|
|
60
|
+
const nodes = [...(atRule.nodes ?? [])];
|
|
61
|
+
if (!nodes.some((node) => node.type === 'decl')) return;
|
|
62
|
+
const rebuilt: ChildNode[] = [];
|
|
63
|
+
let declarations: ChildNode[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (declarations.length === 0) return;
|
|
66
|
+
const rule = postcss.rule({ selector: parentSelector });
|
|
67
|
+
rule.append(declarations);
|
|
68
|
+
rebuilt.push(rule);
|
|
69
|
+
declarations = [];
|
|
70
|
+
};
|
|
71
|
+
for (const node of nodes) {
|
|
72
|
+
if (node.type === 'decl') declarations.push(node);
|
|
73
|
+
else {
|
|
74
|
+
flush();
|
|
75
|
+
rebuilt.push(node);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
atRule.removeAll();
|
|
80
|
+
atRule.append(rebuilt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Hoist `rule`'s nested at-rules into its own container. Reports whether it moved any. */
|
|
84
|
+
function hoistNestedAtRules(rule: Rule): boolean {
|
|
85
|
+
const container = rule.parent;
|
|
86
|
+
// Snapshot: reparenting the declarations empties `rule.nodes` mid-iteration.
|
|
87
|
+
const nodes = [...(rule.nodes ?? [])];
|
|
88
|
+
if (!container || !nodes.some((node) => node.type === 'atrule')) return false;
|
|
89
|
+
|
|
90
|
+
const replacement: ChildNode[] = [];
|
|
91
|
+
let declarations: ChildNode[] = [];
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (declarations.length === 0) return;
|
|
94
|
+
const segment = postcss.rule({ selector: rule.selector, raws: { ...rule.raws } });
|
|
95
|
+
segment.append(declarations);
|
|
96
|
+
replacement.push(segment);
|
|
97
|
+
declarations = [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
if (node.type !== 'atrule') {
|
|
102
|
+
declarations.push(node);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
const hoisted = node.clone();
|
|
107
|
+
resolveAmpersands(hoisted, rule.selector);
|
|
108
|
+
wrapBareDeclarations(hoisted, rule.selector);
|
|
109
|
+
replacement.push(hoisted);
|
|
110
|
+
}
|
|
111
|
+
flush();
|
|
112
|
+
|
|
113
|
+
container.insertBefore(rule, replacement);
|
|
114
|
+
rule.remove();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* PostCSS plugin: hoist nested at-rules out of the rules containing them.
|
|
120
|
+
*
|
|
121
|
+
* `OnceExit` rather than `Once` so Tailwind's own pass has already expanded
|
|
122
|
+
* `@theme` and every utility before there is a rule to flatten; register it
|
|
123
|
+
* LAST for the same reason.
|
|
124
|
+
*/
|
|
125
|
+
export function flattenNestedAtRules(): Plugin {
|
|
126
|
+
return {
|
|
127
|
+
postcssPlugin: 'ompchamber-flatten-nested-at-rules',
|
|
128
|
+
OnceExit(root) {
|
|
129
|
+
// A hoist can expose another nested at-rule in the segment that received
|
|
130
|
+
// it, so repeat until a full pass moves nothing.
|
|
131
|
+
let hoisted = true;
|
|
132
|
+
while (hoisted) {
|
|
133
|
+
hoisted = false;
|
|
134
|
+
const rules: Rule[] = [];
|
|
135
|
+
root.walkRules((rule) => {
|
|
136
|
+
rules.push(rule);
|
|
137
|
+
});
|
|
138
|
+
// Reverse document order: a nested rule is walked after its parent, so
|
|
139
|
+
// the inner one is flattened first and its parent is then still intact.
|
|
140
|
+
for (let index = rules.length - 1; index >= 0; index -= 1) {
|
|
141
|
+
if (hoistNestedAtRules(rules[index])) hoisted = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -44,7 +44,11 @@ export function createDb(path: string): DbClient {
|
|
|
44
44
|
return prep(sql).all(...(params as never[])) as T[];
|
|
45
45
|
},
|
|
46
46
|
async run(sql: string, params: any[] = []) {
|
|
47
|
-
|
|
47
|
+
// Through `prep`, like `get`/`all`: `Database.run` does not cache its
|
|
48
|
+
// query (it prepares and finalizes per call), which cost every INSERT /
|
|
49
|
+
// UPDATE / DELETE a compile — measured 0.881 µs/op against 0.627 µs/op
|
|
50
|
+
// for the cached statement.
|
|
51
|
+
const result = prep(sql).run(...(params as never[]));
|
|
48
52
|
return { changes: result.changes, lastID: Number(result.lastInsertRowid) };
|
|
49
53
|
},
|
|
50
54
|
async exec(sql: string) {
|
|
@@ -2,20 +2,32 @@ import path from 'path';
|
|
|
2
2
|
import type { FileDiffData } from '@/shared/types/git';
|
|
3
3
|
import { runShell, shellOk } from '@/server/lib/fs/shell';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Context lines requested when the reader wants the whole file rather than the
|
|
7
|
+
* hunks. `--unified` takes any count, so this is a cap on the FILE, not on the
|
|
8
|
+
* change: git emits every line it can reach around the edits.
|
|
9
|
+
*/
|
|
10
|
+
const FULL_CONTEXT = 1000000;
|
|
11
|
+
|
|
5
12
|
/** Runs one git diff probe and returns its stdout ('' on failure). */
|
|
6
|
-
async function gitOut(command: string, cwd: string, timeout: number): Promise<string> {
|
|
7
|
-
const result = await runShell(command, { cwd, timeout, maxBuffer
|
|
13
|
+
async function gitOut(command: string, cwd: string, timeout: number, maxBuffer: number = 1024 * 1024 * 2): Promise<string> {
|
|
14
|
+
const result = await runShell(command, { cwd, timeout, maxBuffer });
|
|
8
15
|
return shellOk(result) ? result.stdout : '';
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Fetch git diff for a specific file in the working directory or index.
|
|
20
|
+
*
|
|
21
|
+
* `fullContext` asks git for the entire file rather than the surrounding hunks,
|
|
22
|
+
* which is what the panel's "Full Code" mode renders — the diff comes back in
|
|
23
|
+
* the same unified format, so no second rendering path exists.
|
|
13
24
|
*/
|
|
14
25
|
export async function fetchWorkingFileDiff(
|
|
15
26
|
targetDir: string,
|
|
16
27
|
file: string,
|
|
17
28
|
staged: boolean = false,
|
|
18
|
-
requestedStatus?: string
|
|
29
|
+
requestedStatus?: string,
|
|
30
|
+
fullContext: boolean = false
|
|
19
31
|
): Promise<FileDiffData> {
|
|
20
32
|
const cleanFile = file.replace(/^[./\\]+/, '').replace(/\\/g, '/');
|
|
21
33
|
const fullPath = path.join(targetDir, cleanFile);
|
|
@@ -45,21 +57,33 @@ export async function fetchWorkingFileDiff(
|
|
|
45
57
|
oldContent = await gitOut(`git show HEAD:"${cleanFile}"`, targetDir, 5000)
|
|
46
58
|
|| await gitOut(`git show :0:"${cleanFile}"`, targetDir, 5000);
|
|
47
59
|
|
|
60
|
+
// `-U<n>` on every probe: the full-code mode asks for the whole file, and
|
|
61
|
+
// the diff-only mode keeps git's own default by passing no flag at all.
|
|
62
|
+
const ctx = fullContext ? ` -U${FULL_CONTEXT}` : '';
|
|
63
|
+
// Full-context output is the file, not the change, so a fixed buffer would
|
|
64
|
+
// truncate a large file's diff — and a killed process is not a success, so
|
|
65
|
+
// `gitOut` reports it as no diff at all and the synthesized whole-file
|
|
66
|
+
// rewrite below takes over silently. Sized off the content already read, so
|
|
67
|
+
// it cannot exceed roughly twice the file.
|
|
68
|
+
const buf = fullContext
|
|
69
|
+
? Math.max(1024 * 1024 * 16, (oldContent.length + newContent.length) * 2 + 1024 * 1024)
|
|
70
|
+
: 1024 * 1024 * 2;
|
|
71
|
+
|
|
48
72
|
if (staged) {
|
|
49
73
|
// 1. Try staged diff (index vs HEAD), 2. HEAD diff, 3. unstaged diff
|
|
50
|
-
diff = await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
51
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000)
|
|
52
|
-
|| await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000);
|
|
74
|
+
diff = await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
75
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
76
|
+
|| await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
53
77
|
} else if (status === '??' || status === 'U' || status === '?') {
|
|
54
78
|
// Untracked file: --no-index exits 1 when a diff exists, so read stdout
|
|
55
79
|
// directly instead of relying on the exit code.
|
|
56
|
-
const untracked = await runShell(`git diff --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer:
|
|
80
|
+
const untracked = await runShell(`git diff${ctx} --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer: buf });
|
|
57
81
|
diff = untracked.stdout.trim() ? untracked.stdout : '';
|
|
58
82
|
} else {
|
|
59
83
|
// 1. Working tree diff, 2. cached diff, 3. HEAD diff
|
|
60
|
-
diff = await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000)
|
|
61
|
-
|| await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
62
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000);
|
|
84
|
+
diff = await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
85
|
+
|| await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
86
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
// 4. If git diff is still empty but new content exists:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested-repository discovery for the right-panel repo picker.
|
|
8
|
+
*
|
|
9
|
+
* A workspace is often a folder OF repositories (`projects/<name>/<sub>/.git`),
|
|
10
|
+
* so the picker lists every repo under the scoped root. The walk is expensive
|
|
11
|
+
* on a deep tree, so it runs in the BACKGROUND after the loader has already
|
|
12
|
+
* answered with the root's own status; the client polls until `pending` clears.
|
|
13
|
+
* Results are cached per scoped root for the life of the process.
|
|
14
|
+
*
|
|
15
|
+
* The `find` prunes `node_modules` (the one directory guaranteed to hold
|
|
16
|
+
* unrelated `.git` directories, and the one that makes the walk unbounded).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { runShell } from '@/server/lib/fs/shell';
|
|
20
|
+
|
|
21
|
+
const MAX_GIT_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
async function getRepos(rootDir: string): Promise<string[]> {
|
|
24
|
+
try {
|
|
25
|
+
// Search deep enough to discover nested/child git repos inside a monorepo
|
|
26
|
+
// workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
|
|
27
|
+
const result = await runShell(
|
|
28
|
+
`find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
|
|
29
|
+
{ cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
|
|
30
|
+
);
|
|
31
|
+
const discovered = result.stdout
|
|
32
|
+
.trim()
|
|
33
|
+
.split('\n')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map(line => {
|
|
36
|
+
const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
|
|
37
|
+
return cleaned === '.git' || !cleaned ? '.' : cleaned;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const uniqueRepos = Array.from(new Set(discovered));
|
|
41
|
+
if (uniqueRepos.includes('.')) {
|
|
42
|
+
return ['.', ...uniqueRepos.filter(r => r !== '.')];
|
|
43
|
+
}
|
|
44
|
+
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
45
|
+
} catch {
|
|
46
|
+
return ['.'];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RepoDiscovery {
|
|
51
|
+
repos: string[];
|
|
52
|
+
done: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const repoDiscovery = new Map<string, RepoDiscovery>();
|
|
56
|
+
|
|
57
|
+
/** Begin the background walk for `rootDir`; a no-op once one has started. */
|
|
58
|
+
export function startRepoScan(rootDir: string): void {
|
|
59
|
+
if (repoDiscovery.has(rootDir)) return;
|
|
60
|
+
const d: RepoDiscovery = { repos: ['.'], done: false };
|
|
61
|
+
repoDiscovery.set(rootDir, d);
|
|
62
|
+
void getRepos(rootDir)
|
|
63
|
+
.then(repos => { d.repos = repos; d.done = true; })
|
|
64
|
+
.catch(() => { d.repos = ['.']; d.done = true; });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drop the cached result for `rootDir` and start a fresh walk. Used by the
|
|
69
|
+
* repo-switcher's refresh button, which must not serve the previous answer.
|
|
70
|
+
*/
|
|
71
|
+
export function rescanRepos(rootDir: string): void {
|
|
72
|
+
repoDiscovery.delete(rootDir);
|
|
73
|
+
startRepoScan(rootDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The discovered repos for `rootDir`, and whether the walk is still running. */
|
|
77
|
+
export function discoveredRepos(rootDir: string): { repos: string[]; pending: boolean } {
|
|
78
|
+
const d = repoDiscovery.get(rootDir);
|
|
79
|
+
if (!d) return { repos: ['.'], pending: false };
|
|
80
|
+
return { repos: d.done ? d.repos : ['.'], pending: !d.done };
|
|
81
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `flock(2)` through `bun:ffi`, for the port-handover lock.
|
|
8
|
+
*
|
|
9
|
+
* The kernel releases a flock when the holding process dies, which is exactly
|
|
10
|
+
* the property a startup lock wants — no PID readback, no stale-holder
|
|
11
|
+
* classification, and no polling loop to detect a dead holder. The previous
|
|
12
|
+
* PID-file scheme needed all three: it wrote a PID, re-read it on every retry,
|
|
13
|
+
* asked the OS whether that PID was still OMPChamber (four `sysctl` calls per
|
|
14
|
+
* question on macOS), and deleted the file to release.
|
|
15
|
+
*
|
|
16
|
+
* `flock` is exported by `libc.dylib` / `libSystem.B.dylib` / `libc.so.6`, so it
|
|
17
|
+
* is reachable the same way `lib/lifecycle/proc/darwin.ts` reaches `sysctl`.
|
|
18
|
+
* Windows has no `flock`, and a host can refuse `dlopen` or answer `ENOLCK`
|
|
19
|
+
* (some NFS mounts) — `flockAvailable()` is a real capability probe, and every
|
|
20
|
+
* caller keeps its own fallback for the false case.
|
|
21
|
+
*
|
|
22
|
+
* **The lock file is never unlinked while it may be held.** Removing it is the
|
|
23
|
+
* classic flock race: another process that already opened the path keeps a
|
|
24
|
+
* handle on the unlinked inode and acquires it successfully while a third
|
|
25
|
+
* process creates a fresh file at the same path and also succeeds. Leaving the
|
|
26
|
+
* file in place makes the inode the lock, which is what flock is for; the file
|
|
27
|
+
* is an empty marker, one per port.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'fs';
|
|
31
|
+
import { dlopen, FFIType } from 'bun:ffi';
|
|
32
|
+
import type { FFIFunction, Library } from 'bun:ffi';
|
|
33
|
+
|
|
34
|
+
const LOCK_EX = 2;
|
|
35
|
+
/** Non-blocking: fail immediately rather than waiting inside the syscall. */
|
|
36
|
+
const LOCK_NB = 4;
|
|
37
|
+
|
|
38
|
+
const FLOCK_SYMBOLS = {
|
|
39
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
40
|
+
} satisfies Record<string, FFIFunction>;
|
|
41
|
+
|
|
42
|
+
/** Library names to try, in the order each platform resolves them. */
|
|
43
|
+
const LIBC_NAMES = ['libc.dylib', 'libSystem.B.dylib', 'libc.so.6'] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Held leases and the probe result, on `globalThis` rather than in module
|
|
47
|
+
* bindings.
|
|
48
|
+
*
|
|
49
|
+
* A `bun --hot` reload re-evaluates this module while the process (and its open
|
|
50
|
+
* descriptors) lives on. A fresh module-level Map would forget that this PID
|
|
51
|
+
* already holds the port's lock, so the reloaded server would open a second fd,
|
|
52
|
+
* find the lock taken by its own previous fd, and refuse to start.
|
|
53
|
+
*/
|
|
54
|
+
interface FlockHost {
|
|
55
|
+
/** `lockPath` → the fd holding it and how many acquires it backs. */
|
|
56
|
+
held: Map<string, { fd: number; refs: number }>;
|
|
57
|
+
/** Tri-state capability probe: undefined until asked, then lib-or-null. */
|
|
58
|
+
available: boolean | undefined;
|
|
59
|
+
/** The opened library, shared across reloads. */
|
|
60
|
+
lib: Library<typeof FLOCK_SYMBOLS> | null | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare global {
|
|
64
|
+
// eslint-disable-next-line no-var
|
|
65
|
+
var __ompChamberFlock: FlockHost | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function host(): FlockHost {
|
|
69
|
+
return (globalThis.__ompChamberFlock ??= { held: new Map(), available: undefined, lib: undefined });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** libc with `flock`, or null when this host has none. Cached for the process. */
|
|
73
|
+
function libc(): Library<typeof FLOCK_SYMBOLS> | null {
|
|
74
|
+
const state = host();
|
|
75
|
+
if (state.lib !== undefined) return state.lib;
|
|
76
|
+
state.lib = null;
|
|
77
|
+
if (process.platform === 'win32') return state.lib;
|
|
78
|
+
for (const name of LIBC_NAMES) {
|
|
79
|
+
try {
|
|
80
|
+
state.lib = dlopen(name, FLOCK_SYMBOLS);
|
|
81
|
+
return state.lib;
|
|
82
|
+
} catch {
|
|
83
|
+
// Try the next name; a host without any of them keeps the null answer.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return state.lib;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True when `flock(2)` actually works here — the symbol resolving is not
|
|
91
|
+
* enough, because a filesystem can answer `ENOLCK`. Probed once, on a
|
|
92
|
+
* throwaway file, so a caller can pick its mechanism up front instead of
|
|
93
|
+
* discovering mid-wait that the syscall never works.
|
|
94
|
+
*/
|
|
95
|
+
export function flockAvailable(): boolean {
|
|
96
|
+
const state = host();
|
|
97
|
+
if (state.available !== undefined) return state.available;
|
|
98
|
+
state.available = false;
|
|
99
|
+
const symbols = libc();
|
|
100
|
+
if (!symbols) return false;
|
|
101
|
+
const probePath = `${process.env.TMPDIR ?? '/tmp'}/ompchamber-flock-${process.pid}`;
|
|
102
|
+
let fd: number | null = null;
|
|
103
|
+
try {
|
|
104
|
+
fd = fs.openSync(probePath, 'a');
|
|
105
|
+
const ok = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
fd = null;
|
|
108
|
+
fs.rmSync(probePath, { force: true });
|
|
109
|
+
if (!ok) {
|
|
110
|
+
// The probe file was somehow already locked — treat the capability as
|
|
111
|
+
// unproven rather than reporting a lock we do not hold.
|
|
112
|
+
state.lib = null;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
state.available = true;
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
state.lib = null;
|
|
119
|
+
return false;
|
|
120
|
+
} finally {
|
|
121
|
+
if (fd !== null) {
|
|
122
|
+
try {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
} catch {
|
|
125
|
+
// Already closed.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface FlockHandle {
|
|
132
|
+
/** Release the lease. Idempotent. */
|
|
133
|
+
release: () => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Take `LOCK_EX | LOCK_NB` on `lockPath`, or null when another process holds
|
|
138
|
+
* it. Returns null too when `flock` is unavailable — callers must then use
|
|
139
|
+
* their own mechanism.
|
|
140
|
+
*
|
|
141
|
+
* The fd is kept open for as long as the lease lives: closing it is what
|
|
142
|
+
* releases the lock, and the kernel does the same on process death.
|
|
143
|
+
*/
|
|
144
|
+
export function tryFlock(lockPath: string): FlockHandle | null {
|
|
145
|
+
const state = host();
|
|
146
|
+
const existing = state.held.get(lockPath);
|
|
147
|
+
if (existing) {
|
|
148
|
+
// Re-entrant within one process: flock is per open-file-description, so a
|
|
149
|
+
// second fd here would block against our own lock.
|
|
150
|
+
existing.refs += 1;
|
|
151
|
+
return { release: () => releaseFlock(lockPath) };
|
|
152
|
+
}
|
|
153
|
+
const symbols = libc();
|
|
154
|
+
if (!symbols) return null;
|
|
155
|
+
let fd: number;
|
|
156
|
+
try {
|
|
157
|
+
// `a` creates it when absent and never truncates; the file's content is
|
|
158
|
+
// irrelevant (the inode is the lock), but the PID is useful when a human
|
|
159
|
+
// looks at the directory.
|
|
160
|
+
fd = fs.openSync(lockPath, 'a');
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let acquired = false;
|
|
165
|
+
try {
|
|
166
|
+
acquired = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
167
|
+
} catch {
|
|
168
|
+
acquired = false;
|
|
169
|
+
}
|
|
170
|
+
if (!acquired) {
|
|
171
|
+
try {
|
|
172
|
+
fs.closeSync(fd);
|
|
173
|
+
} catch {
|
|
174
|
+
// Already closed.
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
180
|
+
} catch {
|
|
181
|
+
// Informational only.
|
|
182
|
+
}
|
|
183
|
+
state.held.set(lockPath, { fd, refs: 1 });
|
|
184
|
+
return { release: () => releaseFlock(lockPath) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function releaseFlock(lockPath: string): void {
|
|
188
|
+
const state = host();
|
|
189
|
+
const entry = state.held.get(lockPath);
|
|
190
|
+
if (!entry) return;
|
|
191
|
+
entry.refs -= 1;
|
|
192
|
+
if (entry.refs > 0) return;
|
|
193
|
+
state.held.delete(lockPath);
|
|
194
|
+
try {
|
|
195
|
+
fs.closeSync(entry.fd);
|
|
196
|
+
} catch {
|
|
197
|
+
// Already closed; the kernel released the lock either way.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -37,7 +37,11 @@ export type ProcessState = 'dead' | 'matched' | 'mismatched' | 'unknown';
|
|
|
37
37
|
*/
|
|
38
38
|
export function isProcessAlive(pid: number): boolean {
|
|
39
39
|
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
40
|
-
|
|
40
|
+
const liveness = processProbe.liveness(pid);
|
|
41
|
+
// A zombie is NOT alive: it holds no port and will never run again. An
|
|
42
|
+
// `unknown` (a probe that could not answer) keeps the previous bias and
|
|
43
|
+
// counts as alive, so a sandboxed host cannot make a live server look dead.
|
|
44
|
+
return liveness === 'alive' || liveness === 'unknown';
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
3
|
+
import { isCliManaged, launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
4
4
|
|
|
5
5
|
const ENTRY = '/repo/src/server/index.ts';
|
|
6
6
|
|
|
@@ -30,3 +30,22 @@ describe('resolveLaunchMode', () => {
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
// Every command that would END a server decides with this predicate:
|
|
35
|
+
// `restart`/`update` may replace it, and `stop` stops it by default. Guessing
|
|
36
|
+
// wrong here kills a `bun run dev` the user owns — that launcher exits with its
|
|
37
|
+
// child — or leaves a daemon serving the build that was just replaced.
|
|
38
|
+
describe('isCliManaged', () => {
|
|
39
|
+
test('owns the instances the CLI started', () => {
|
|
40
|
+
expect(isCliManaged('daemon')).toBe(true);
|
|
41
|
+
expect(isCliManaged('foreground')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('leaves every instance OMPChamber did not start alone', () => {
|
|
45
|
+
// `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
|
|
46
|
+
expect(isCliManaged('direct')).toBe(false);
|
|
47
|
+
// A probed or unreadable record never authorises a kill either.
|
|
48
|
+
expect(isCliManaged('unknown')).toBe(false);
|
|
49
|
+
expect(isCliManaged(undefined)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -38,3 +38,29 @@ export function resolveLaunchMode(argv: readonly string[] = Bun.argv): InstanceL
|
|
|
38
38
|
const value = flag?.slice(LAUNCH_MODE_FLAG.length);
|
|
39
39
|
return value === 'daemon' || value === 'foreground' ? value : 'direct';
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the CLI owns this instance's lifecycle — the one question every
|
|
44
|
+
* command that would end a server asks: `restart`/`update` may replace it, and
|
|
45
|
+
* `stop` stops it by default.
|
|
46
|
+
*
|
|
47
|
+
* A `direct` server runs under a launcher the CLI does not own, and that
|
|
48
|
+
* launcher exits with its child: `bun run dev` is a supervisor process whose
|
|
49
|
+
* only job is to mirror the script it spawned, so signalling the server ends
|
|
50
|
+
* the user's dev loop with it. Leaving those alone by default is what keeps
|
|
51
|
+
* `ompchamber stop` from killing a loop it did not start; an explicit
|
|
52
|
+
* `--port <port>` (or `--all`) still ends one, deliberately.
|
|
53
|
+
*/
|
|
54
|
+
export function isCliManaged(launchMode: string | null | undefined): boolean {
|
|
55
|
+
return launchMode === 'daemon' || launchMode === 'foreground';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why an instance the CLI does not own is left alone, without the action. Each
|
|
60
|
+
* command that would end a server appends its own next step, so the two cannot
|
|
61
|
+
* describe the same `launchMode` differently.
|
|
62
|
+
*/
|
|
63
|
+
export function unmanagedReason(launchMode: string | null | undefined): string {
|
|
64
|
+
if (launchMode === 'direct') return 'runs from source (`bun run dev` / `bun run start`)';
|
|
65
|
+
return 'is not managed by the ompchamber CLI';
|
|
66
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import fs from 'fs';
|
|
24
24
|
|
|
25
|
+
import { flockAvailable, tryFlock } from '@/server/lib/lifecycle/flock';
|
|
25
26
|
import { getProcessState } from '@/server/lib/lifecycle/identity';
|
|
26
27
|
import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
|
|
27
28
|
import { getLockPath } from '@/server/lib/lifecycle/paths';
|
|
@@ -48,13 +49,27 @@ export interface PortLock {
|
|
|
48
49
|
* Two starters racing on one free port would each probe it as free and then
|
|
49
50
|
* fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
|
|
50
51
|
* process it cannot identify yet. Holding the lock across probe-and-bind makes
|
|
51
|
-
* the loser fail with a message that names the actual situation.
|
|
52
|
-
* holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
|
|
52
|
+
* the loser fail with a message that names the actual situation.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
54
|
+
* The mechanism is `flock(2)` when the host has it (see `flock.ts`): the kernel
|
|
55
|
+
* releases it on process death, so there is no stale-holder case to detect and
|
|
56
|
+
* no polling loop. The PID-file path below stays as the fallback for a host
|
|
57
|
+
* without `flock` (Windows, a filesystem answering `ENOLCK`); it needs the
|
|
58
|
+
* extra machinery — PID readback, `getProcessState` classification, and
|
|
59
|
+
* reclaiming a lock whose holder died or whose PID was recycled.
|
|
55
60
|
*/
|
|
56
61
|
export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
|
|
57
62
|
const lockPath = getLockPath(port);
|
|
63
|
+
if (flockAvailable()) {
|
|
64
|
+
const deadline = Date.now() + waitMs;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const handle = tryFlock(lockPath);
|
|
67
|
+
if (handle) return handle;
|
|
68
|
+
if (Date.now() >= deadline) return null;
|
|
69
|
+
await Bun.sleep(150);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const deadline = Date.now() + waitMs;
|
|
59
74
|
for (;;) {
|
|
60
75
|
try {
|